From 6db0cabdf0e60ad4aebad262d0b8b52fc126b92d Mon Sep 17 00:00:00 2001 From: r33drichards Date: Thu, 13 Aug 2026 11:22:35 -0700 Subject: [PATCH 001/117] docs(sandbox): add a how-to for running Minecraft in a Windows sandbox (#3131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(sandbox): add a how-to for running Minecraft in a Windows sandbox Walks through booting Image.windows() on the local bare-metal QEMU runtime, installing Minecraft Java Edition inside it, and handing the launcher to a computer-use agent. The page documents the four traps that make this fail otherwise: - the bare-metal netdev is restrict=on, so the guest has no internet until a second NIC is added, and that NIC needs its own subnet or both user-mode networks offer the guest the same address - the default qemu64 CPU model is too thin for a software OpenGL driver: the game creates its window and then dies during resource loading with no Java exception, which -cpu host fixes - MSVC builds of both Prism Launcher and Mesa3D need the VC++ redistributable that Windows Server 2022 does not ship, so the MinGW builds are the ones that work - the launcher downloads its own Java, so the Mesa DLLs have to land next to that javaw.exe rather than a separately installed JRE Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): drop the obsolete shared-efivars troubleshooting row EFI variables stopped being shared in #3128: the bare-metal QEMU runtime now derives one pflash file per VM from its disk name (Path(disk_path).with_suffix(".efivars.fd"), runtime/qemu.py:345), and tests/test_docs_regressions.py asserts the old shared path is gone. There is no ~/.cua/cua-sandbox/images/sessions/efivars.fd to delete anymore, so the row pointed readers at a file that does not exist. * docs(sandbox): drive Minecraft through cua-driver's MCP server Rewrites the agent half of the guide around the MCP server the sandbox already runs, rather than ComputerAgent: Image.windows().expose(3000) publishes cua-driver's endpoint on guest :3000, a FastMCP client lists its tools, and a small loop hands them to a model as ordinary function tools. The same loop runs against a local sandbox and against Fleet; only the transport differs. Presenting MCP tools as ordinary function tools also sidesteps endpoints that refuse provider-native computer-use tool types. Documents three things about cua-driver that are otherwise a surprise: a YAML policy refuses tools that list_tools() still advertises, clicks are addressed to a pid rather than the screen, and there is no wait tool so the loop waits by re-screenshotting. Also adds prerequisites (cua-sandbox 0.3.1+, and that -cpu host needs KVM or HVF, so an x86_64 guest on Apple Silicon cannot use the local path), a delivery snippet for the Mesa script, and replaces the stale Fleet callout now that Windows boots there. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): use the public tunnel API and make the sample match the run Replaces the hand-assembled Fleet service URL with sb.tunnel.forward(3000), which returns exactly that URL and is already documented in tunneling.mdx. The local path keeps reading the port off the object Sandbox.create returns, because tunnel.forward() is not implemented for that transport and the port is not recoverable after a reconnect — now stated outright rather than left for a reader to discover. The agent sample now does what the prose said it did: streams and rebuilds with stream_chunk_builder, folds the system prompt into the first user turn, prunes old screenshots, and trims the 55-tool listing to the handful the task needs. Also explains why -cpu host is absent on Fleet, attributes the token lifetime to what was observed rather than asserting it, and drops an unused import. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): drop the /docs prefix from the tunneling cross-link Internal doc links in docs/content are written relative to the docs root without a /docs segment — every other absolute link in the tree does it this way (for example ](/how-to-guides/sandbox/images)), and there were zero occurrences of ](/docs/ before this one. The prefixed form resolves to /docs/how-to-guides/... at render time and 404s. * docs(sandbox): record that Minecraft does not yet run on Fleet The Fleet path is verified as far as the sandbox and the agent: it boots, .expose(3000) publishes cua-driver, the MCP client lists the same tools, the setup script installs Prism and Mesa, the Microsoft sign-in completes, and the agent loop drives the desktop. The game itself exits during resource loading every time, with no Java exception, no hs_err and nothing in the event log. Neither known cause explains it: the Fleet CPU reports full AVX-512, so the qemu64 instruction theory does not apply, and forcing the heap from Prism's auto-sized -Xmx2717m down to -Xmx1024m on the 4 GB sandbox changed nothing. Unlike the local path there are no QEMU arguments to adjust. Says so plainly rather than implying the game runs there. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): pin down the Fleet crash with an exit code and a second version Adds the evidence that narrows it: the game dies with exitcode -2147024809, and it is not version-specific — 1.20.1 on Java 17 with LWJGL 3 and 1.12.2 on Java 8 with LWJGL 2 stop at the same point. That exit code is the same one the local qemu64 CPU model produced, but the Fleet CPU reports AVX, AVX2 and AVX-512 present, so the local explanation does not carry over. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): rule out machine size for the Fleet crash Records the last elimination so nobody repeats the experiment: the same local session disk, rebooted with -m 4096 -smp 4 to match the Fleet sandbox exactly, runs Minecraft fine. Prism warns about low free memory and the game launches anyway. So the Fleet failure is not resources. Five things are now ruled out -- CPU feature flags, heap size, Minecraft version, Java/LWJGL generation, and machine size -- with the same stopping point and the same exitcode -2147024809 throughout. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): restore the tunneling cross-link path The /docs prefix came back in 2a4c385 when the surrounding Fleet paragraph was rewritten, undoing 5f19e66. Internal links in docs/content omit the /docs segment — every other absolute link in the tree is written ](/how-to-guides/...) and this is the only ](/docs/ in it — so the prefixed form 404s at render time. * docs(sandbox): stop sending Apple Silicon readers to Fleet for the game The prerequisites and the -cpu host troubleshooting row both told readers without KVM or HVF to use the Fleet path instead. Since 93c1cdd the guide also records that Minecraft does not run on Fleet, so those two lines sent the one reader who cannot use the local path to the one path where the game is known not to work. Both now say what Fleet does get you — the sandbox and the agent loop — and point at the Fleet section for the rest. * docs(sandbox): Minecraft runs on Fleet with GALLIUM_DRIVER=softpipe The Fleet crash was Mesa's llvmpipe renderer. Switching to softpipe fixes it, and an agent drove the same loop from launcher to standing in a new world on Fleet in 51 steps. The section documents the workaround instead of the failure, and the Apple Silicon note now sends readers to Fleet for the game rather than away from it. Keeps the eliminations as the reason softpipe is needed rather than a guess: not the Minecraft version, not the Java/LWJGL generation, not the heap, not the machine size, and not simply wide-vector codegen -- narrowing llvmpipe with LP_NATIVE_VECTOR_WIDTH=128 did not help. Notes that softpipe has no JIT and is markedly slower, and that the variable must be set in the process that launches the launcher, since a machine-level variable does not reach an already-running process. Also records that Fleet sizing is account-dependent: cpu/memory_mb route through a gated path that returned 403 until a card requirement was lifted. Co-Authored-By: Claude Opus 5 (1M context) * docs(sandbox): make the resource-loading crash findable for Fleet readers The troubleshooting table had one row for "game exits during resource loading", attributing it to the qemu64 CPU model and prescribing -cpu host. Since b8ab92f the same symptom has a second, unrelated cause on Fleet — Mesa's llvmpipe renderer — and -cpu host is not available there at all, because Fleet takes no runtime= and no QEMU arguments. A Fleet reader looking the symptom up was sent to a fix they cannot apply, while the one that works had no row. Scope the existing row to local, add a Fleet row pointing at GALLIUM_DRIVER=softpipe, and note in the Fleet callout that setup.ps1 has already started Prism without the variable, so the launcher has to be restarted for it to take effect. * docs(sandbox): catch up with the exposed-ports and tool-listing fixes Two changes merged to main after this page was written and left parts of it describing behaviour that no longer exists. #3133 added a public Sandbox.exposed_ports property that falls back to the saved sandbox state, so the page no longer needs to reach into _runtime_info, and the callout warning that a reconnecting caller cannot recover the port is simply wrong now. Rewrite it around the property, keeping the local/Fleet split the property's own docstring draws: exposed_ports locally, tunnel.forward() on Fleet, where it is empty. #3132 filters the MCP tools/list roster through the capability policy, so denied tools are no longer advertised. The page presented "list_tools() advertises everything, the policy refuses at call time" as the rule; that holds only for driver builds predating the fix. Attribute the 55-tool observation to the image used here, say the listing may already be filtered, and tell readers not to assume the listing and the callable set match in either direction. The client-side trim is unaffected and still worth doing, so its rationale is restated without leaning on the count. * docs(sandbox): state the tool-listing behaviour as fact, not a hedge Every published cua-driver release advertises the full tool surface and refuses out-of-policy calls only at call time: tags 0.19.2 (2026-08-07), 0.19.3 (2026-08-10) and the 0.19.4 nightly (2026-08-12) all predate the change that filters the listing, which merged on 2026-08-13. So for any reader today this is simply how it behaves, and the previous "depends on your image / do not assume either direction" wording cost them a concrete expectation for no gain. State it plainly, keep the probe-your-own-image advice for the allow list itself, and note that later drivers make the listing and the callable set agree. The troubleshooting row is scoped the same way. * docs(sandbox): raise the version floor to 0.3.2 for sb.exposed_ports I moved the page onto the public Sandbox.exposed_ports property in b2bebc1 without revisiting the prerequisite, which still said 0.3.1. The property arrived with #3133, merged 2026-08-13T17:49Z — after cua-sandbox 0.3.1 went to PyPI at 04:07 that morning and before 0.3.2 at 17:54. So a reader installing the version the page asked for gets AttributeError on the page's own first example. Floor is 0.3.2. The bullet now names what each version is needed for, so the next person to use a newer API has the mapping in front of them. --------- Co-authored-by: Robert Wendt Co-authored-by: Claude Opus 5 (1M context) --- .../docs/how-to-guides/sandbox/meta.json | 3 +- .../docs/how-to-guides/sandbox/minecraft.mdx | 368 ++++++++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/how-to-guides/sandbox/minecraft.mdx diff --git a/docs/content/docs/how-to-guides/sandbox/meta.json b/docs/content/docs/how-to-guides/sandbox/meta.json index 508b827e7a..3986ad1cf1 100644 --- a/docs/content/docs/how-to-guides/sandbox/meta.json +++ b/docs/content/docs/how-to-guides/sandbox/meta.json @@ -8,6 +8,7 @@ "scale-out", "tunneling", "images", - "interactive-shell" + "interactive-shell", + "minecraft" ] } diff --git a/docs/content/docs/how-to-guides/sandbox/minecraft.mdx b/docs/content/docs/how-to-guides/sandbox/minecraft.mdx new file mode 100644 index 0000000000..e98f2119bc --- /dev/null +++ b/docs/content/docs/how-to-guides/sandbox/minecraft.mdx @@ -0,0 +1,368 @@ +--- +title: Run Minecraft in a Windows sandbox +description: Boot a Windows sandbox, install Minecraft Java Edition, and drive it with an agent through the cua-driver MCP server running inside the sandbox. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +Minecraft exercises almost everything a Windows sandbox can do: it needs internet access, a Java runtime, working OpenGL, and a GUI that only clicks can drive. This guide boots a Windows sandbox, installs Minecraft Java Edition, and hands it to an agent that talks to **cua-driver's MCP server inside the sandbox** — the same loop against a local sandbox and against Fleet. + +## Before you start + +- **cua-sandbox 0.3.2 or newer.** Windows on Fleet needs 0.3.0, `Image.expose()` on the local QEMU runtime landed in 0.3.1, and the `sb.exposed_ports` this guide reads the forwarded port from landed in 0.3.2. +- **A host with hardware virtualisation** for the local path — a Linux x86_64 machine with `/dev/kvm`, or an Intel Mac. This guide passes `-cpu host`, which QEMU only accepts with KVM or HVF. An x86_64 guest on Apple Silicon runs under TCG emulation, where `-cpu host` is rejected outright. The Fleet path runs there instead, including the game, with the one extra environment variable described in the Fleet section below. +- **A Microsoft account that owns Minecraft Java Edition.** Signing in uses Microsoft device authorization, so one step in the middle is manual: a code appears inside the sandbox and you approve it in your own browser. +- **A vision-capable LLM endpoint** for the agent loop. + +## Boot a Windows sandbox + +`Image.windows()` resolves to a pinned Windows Server 2022 containerDisk. Three things get added on top of the defaults: + +- **`.expose(3000)`** publishes cua-driver's MCP server, which already runs inside the guest, so the agent can reach it. +- **A second network interface.** The bare-metal runtime attaches its NIC with `restrict=on`, which isolates the guest. `sb.shell.run()` still works over the forwarded port, but nothing inside Windows can reach the internet — and Minecraft needs to. +- **`-cpu host`.** The default `qemu64` model is too thin for a software OpenGL driver: Minecraft creates its window and then dies while loading resources, with no Java exception and no crash log. The last `-cpu` on the command line wins, so appending it is enough. + +```python +import asyncio +from cua import Image, QEMURuntime, Sandbox + +EXTRA_ARGS = [ + # a second, unrestricted user-mode NIC — the default one is restrict=on + '-netdev', 'user,id=net1,net=10.0.3.0/24,host=10.0.3.2,dns=10.0.3.3', + '-device', 'virtio-net-pci,netdev=net1,mac=52:55:00:d1:55:02', + # a CPU the software OpenGL driver can actually use + '-cpu', 'host', +] + +async def main(): + sb = await Sandbox.create( + Image.windows().expose(3000), + name='mc-win', + local=True, + runtime=QEMURuntime( + mode='bare-metal', + cpu_count=12, + memory_mb=16384, + extra_args=EXTRA_ARGS, + ), + ) + mcp_port = sb.exposed_ports[3000] + print(f'cua-driver MCP on http://127.0.0.1:{mcp_port}/mcp') + await sb.disconnect() # the sandbox keeps running + +asyncio.run(main()) +``` + +A warm boot takes about 30 seconds. `exposed_ports` maps each exposed guest port to the host port it landed on, and `GET /healthz` on that port answers `ok` once cua-driver is up. + + + **Read the port from `sb.exposed_ports`, not from a tunnel.** `sb.tunnel.forward(3000)` — the usual way to get a forwarded port, and the one the Fleet section below uses — raises `NotImplementedError: HTTPTransport does not support port forwarding` on the local transport. `exposed_ports` is the local equivalent: the runtime picks a free host port at boot, so the mapping is only knowable at runtime, and it is saved with the sandbox state so a later `Sandbox.connect()` can read it back. On Fleet the property is empty, because Fleet publishes services instead — use `tunnel.forward()` there. + + + + Give the second NIC its own subnet. Both user-mode networks default to `10.0.2.0/24` and both offer the guest `10.0.2.15`, so Windows drops one interface to a `169.254.x.x` link-local address with no gateway and no working DNS. + + +Confirm the guest really has internet before installing anything. + +```python +async with Sandbox.connect('mc-win', local=True) as sb: + check = await sb.shell.run( + 'powershell -Command "(Invoke-WebRequest -UseBasicParsing ' + 'https://piston-meta.mojang.com/mc/game/version_manifest.json).StatusCode"' + ) + print(check.stdout) # 200 +``` + +## Install a launcher and a software OpenGL driver + +The sandbox GPU is the *Microsoft Basic Display Adapter*, which offers OpenGL 1.1. Minecraft 1.17 and later need OpenGL 3.2, so the game needs Mesa3D's `opengl32.dll` (llvmpipe), which implements OpenGL in software. + +Both downloads below are **MinGW** builds on purpose. The MSVC builds of Prism Launcher and Mesa both depend on the Visual C++ redistributable, which Windows Server 2022 does not ship: Prism then exits silently, and Mesa's DLL fails to load so Windows quietly falls back to the system `opengl32.dll`. + +```powershell +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +New-Item -ItemType Directory -Force -Path C:\mc | Out-Null + +# Prism Launcher — signs in with Microsoft device authorization, which needs no browser +Invoke-WebRequest -UseBasicParsing -OutFile C:\mc\prism.zip ` + 'https://github.com/PrismLauncher/PrismLauncher/releases/download/11.0.3/PrismLauncher-Windows-MinGW-w64-Portable-11.0.3.zip' +Expand-Archive C:\mc\prism.zip -DestinationPath C:\mc\prismw -Force + +# 7-Zip, because Mesa ships as .7z +Invoke-WebRequest -UseBasicParsing -OutFile C:\mc\7z.msi 'https://www.7-zip.org/a/7z2408-x64.msi' +Start-Process msiexec.exe -ArgumentList '/i','C:\mc\7z.msi','/qn' -Wait + +# Mesa3D software OpenGL +Invoke-WebRequest -UseBasicParsing -OutFile C:\mc\mesa.7z ` + 'https://github.com/pal1000/mesa-dist-win/releases/download/26.1.6/mesa3d-26.1.6-release-mingw.7z' +& 'C:\Program Files\7-Zip\7z.exe' x C:\mc\mesa.7z -oC:\mc\mesamw -y | Out-Null + +Start-Process -FilePath C:\mc\prismw\prismlauncher.exe -WorkingDirectory C:\mc\prismw +``` + +Save that as `setup.ps1`, push it into the sandbox, and run it. It downloads roughly 100 MB, so allow a generous timeout. + +```python +from pathlib import Path + +async with Sandbox.connect('mc-win', local=True) as sb: + await sb.shell.run('if not exist C:\\mc mkdir C:\\mc') + await sb.files.write_text('C:\\mc\\setup.ps1', Path('setup.ps1').read_text()) + result = await sb.shell.run( + 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\mc\\setup.ps1', + timeout=1800, + ) + print(result.stdout) +``` + +## Sign in and create an instance + +Prism opens a **Quick Setup** wizard on first run. Screenshot the sandbox, click through it, and stop at the account page. + +```python +async with Sandbox.connect('mc-win', local=True) as sb: + Path('sandbox.png').write_bytes(await sb.screenshot()) # look at it + await sb.mouse.click(888, 678) # Next +``` + +1. Work through the wizard to **Accounts → Add Microsoft**. Prism shows a QR code and an eight-character device code. +2. Read the code off a screenshot, open `https://www.microsoft.com/link` in your own browser, enter it, and approve the sign-in. The account then appears with status *Ready*. +3. Click **Add Instance**, search for a version such as `1.20.1`, and click **OK**. Prism downloads the client jar and assets. + + + Device codes expire after about fifteen minutes, but Prism issues a fresh one automatically and keeps polling, so the dialog can be left open. Take a new screenshot to read the current code rather than reusing an old one. + + +## Point the software driver at the launcher's Java + +Click **Launch** once. Prism downloads its own Java runtime and the game fails with `GLFW error 65542: WGL: The driver does not appear to support OpenGL` — expected, because Mesa is not in place yet. + +Prism may keep using the runtime it downloaded even if you set `JavaPath` in its config, so copy the Mesa DLLs next to *every* `javaw.exe` under the install root. Windows loads `opengl32.dll` from the running executable's directory before the system directory, which is what makes this work. + +```powershell +$dirs = Get-ChildItem C:\mc -Recurse -Filter javaw.exe -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty DirectoryName -Unique +foreach ($d in $dirs) { + Copy-Item C:\mc\mesamw\x64\opengl32.dll, C:\mc\mesamw\x64\libgallium_wgl.dll $d -Force + Write-Output "mesa -> $d" +} +``` + +Deliver it the same way as the first script. + +```python +async with Sandbox.connect('mc-win', local=True) as sb: + await sb.files.write_text('C:\\mc\\mesa.ps1', Path('mesa.ps1').read_text()) + result = await sb.shell.run( + 'powershell -NoProfile -ExecutionPolicy Bypass -File C:\\mc\\mesa.ps1', timeout=600, + ) + print(result.stdout) # mesa -> C:\mc\prismw\java\java-runtime-gamma\bin +``` + +Click **Launch** again. The Minecraft title screen appears after a minute or two. + +## Drive it with an agent over MCP + +The sandbox already runs **cua-driver**, which serves an MCP endpoint on guest port 3000 — that is what `.expose(3000)` published. The agent is a small loop: list the MCP tools, hand them to a model as ordinary function tools, call whichever one it picks, feed the result back. + +Three things about cua-driver's tools shape the loop: + +- **A YAML policy governs which tools may actually run, and `list_tools()` does not reflect it.** Every cua-driver release to date advertises the full surface and refuses out-of-policy calls only when you make them, with `Permission denied: user policy: tool 'X' is not allowed by the YAML policy`. So the listing is a menu of what exists, not of what you can call. Here that surface was 55 tools, identically over the local and Fleet transports: `get_desktop_state`, `list_apps`, `list_windows`, `get_window_state`, `click`, `double_click`, `type_text`, `press_key`, `hotkey`, `launch_app`, `bring_to_front`, `scroll` and `drag` ran, while `get_screen_size`, `get_accessibility_tree`, `get_config`, `check_permissions`, `get_cursor_position` and `zoom` were refused. Treat that split as something to probe on your own image rather than a fixed list — a denial arrives before the tool executes, so probing is cheap. Later drivers filter the listing through the policy, at which point the two finally agree. +- **Clicks are addressed to an application, not the screen.** `click(pid=..., x=..., y=...)` targets a window belonging to that pid, which you find with `list_windows`. Add `delivery_mode='foreground'` when a background-delivered click does not land. +- **There is no wait tool.** The loop waits by calling `get_desktop_state` again, so say that in the system prompt or the model will invent something worse. + +```python +import asyncio, json, os +import litellm +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +SYSTEM = """You operate a computer through the provided tools. + +The desktop is Windows at 1280x800. Work in a loop: look at the screen with +get_desktop_state, decide one action, call one tool, then look again. + + * click / type_text / press_key act on a specific application, addressed by + `pid`. Use list_windows to find the pid, then pass pid with x/y. + * There is no wait tool. If something is still loading, call + get_desktop_state again — repeated looks are how you wait. + +Call exactly one tool per turn. When the task is complete, reply DONE.""" + +async def complete(**kwargs): + """Two workarounds for the endpoint used here — yours may need neither. + + It is streaming-only (a plain request comes back with empty output), and it + rejects role=system, so the system prompt travels as the first user turn. + """ + messages, system = [], [] + for m in kwargs['messages']: + (system if m.get('role') == 'system' else messages).append(m) + if system: + text = '\n\n'.join(m['content'] for m in system) + messages = [{'role': 'user', 'content': text}] + messages + kwargs['messages'] = messages + + stream = await litellm.acompletion(**kwargs, stream=True) + chunks = [c async for c in stream] + return litellm.stream_chunk_builder(chunks, messages=messages) + +def prune_images(messages, keep=3): + """Each get_desktop_state returns a full screenshot; keep only the newest.""" + seen = 0 + for msg in reversed(messages): + if not isinstance(msg.get('content'), list): + continue + for part in msg['content']: + if part.get('type') == 'image_url': + seen += 1 + if seen > keep: + part.clear() + part.update({'type': 'text', 'text': '[older screenshot dropped]'}) + return messages + +async def run(mcp_url, task, model, max_steps=60, headers=None): + client = Client(StreamableHttpTransport(mcp_url, headers=headers)) + async with client: + # list_tools() returned 55 tools on the image used here, most of them + # browser and recording plumbing this task never needs. Hand the model + # only what the job requires: dozens of schemas is a lot of context to + # spend, and a shorter menu is a shorter list of ways to go wrong. Worth + # doing whether or not your driver already filters denied tools out. + wanted = { + 'get_desktop_state', 'list_windows', 'list_apps', 'click', + 'double_click', 'type_text', 'press_key', 'hotkey', 'launch_app', + 'bring_to_front', 'scroll', + } + mcp_tools = [t for t in await client.list_tools() if t.name in wanted] + tools = [{ + 'type': 'function', + 'function': { + 'name': t.name, + 'description': (t.description or '')[:800], + 'parameters': t.inputSchema or {'type': 'object', 'properties': {}}, + }, + } for t in mcp_tools] + + messages = [{'role': 'system', 'content': SYSTEM}, + {'role': 'user', 'content': task}] + + for _ in range(max_steps): + resp = await complete( + model=model, messages=prune_images(messages), tools=tools, + tool_choice='auto', temperature=0.0, + ) + msg = resp.choices[0].message + messages.append(msg.model_dump()) + if not msg.tool_calls: + break # model said DONE + + for call in msg.tool_calls: + args = json.loads(call.function.arguments or '{}') + result = await client.call_tool(call.function.name, args, + raise_on_error=False) + text = ''.join(getattr(b, 'text', '') for b in (result.content or [])) + messages.append({'role': 'tool', 'tool_call_id': call.id, + 'name': call.function.name, 'content': text[:1500]}) + + shot = next((b.data for b in (result.content or []) + if getattr(b, 'data', None)), None) + if shot: + messages.append({'role': 'user', 'content': [ + {'type': 'text', 'text': 'screenshot after that action:'}, + {'type': 'image_url', + 'image_url': {'url': f'data:image/png;base64,{shot}'}}, + ]}) +``` + +Point it at the exposed port and give it the task. + +```python +TASK = """ +Prism Launcher is open, with a Minecraft instance and a signed-in account. +Select the instance and click Launch. Minecraft uses a software renderer, so the +window takes minutes to appear and repaints slowly — keep calling +get_desktop_state to watch it, and do not restart anything. On the title screen +click Singleplayer, then Create New World, then Create New World again. Stop as +soon as you are inside the world (terrain in first person, hotbar and hearts +visible) and reply DONE. Never press Escape while Minecraft is in the foreground. +""" + +asyncio.run(run(f'http://127.0.0.1:{mcp_port}/mcp', TASK, 'your-model')) +``` + +Because the MCP tools are presented as **ordinary function tools**, this works against endpoints that reject the provider-native computer-use tool types. That is not hypothetical: on the gateway used here, the same model with the same image in the same second returned 200 for an ordinary function tool and 403 for Anthropic's `computer_20250124`, and `computer_use_preview` was refused outright. + +The `complete()` wrapper above exists only for that gateway — it is streaming-only, and it rejects `role: system`. Against an endpoint without those quirks, call `litellm.acompletion` directly. + +A full run — launcher to standing in a new world — took 52 steps locally and 51 on Fleet, roughly twenty minutes, most of it waiting on the software renderer. Expect the model to spend long stretches doing nothing but re-screenshotting. + + + Give the model help with coordinates. A vision model without grounding guesses pixel positions and misses: in one run an ungrounded model clicked at (1226, 210) four times, nowhere near the button it wanted, then declared it had no desktop tool. cua-driver's `list_windows` and pid-scoped clicks avoid most of this, and a grounding pass over the screenshot removes the rest. + + +## Run the same thing on Fleet + +The image and the agent loop are identical on Fleet. Two things change: there is no `local=True` and no `runtime=`, and the MCP endpoint is reached through Fleet's service proxy rather than a forwarded localhost port. + +`.expose(3000)` becomes a Fleet **service** named `port-3000`, and `sb.tunnel.forward()` hands you its URL — the same call documented in [Forward a port from a sandbox](/how-to-guides/sandbox/tunneling), so there is nothing Fleet-specific to hand-assemble. + +```python +import httpx +from cua import Image, Sandbox + +sb = await Sandbox.create(Image.windows().expose(3000), name='mc-fleet', time_to_start=900) + +tunnel = await sb.tunnel.forward(3000) +# https://run.cua.ai/api/svc//-port-3000/ + +token = httpx.post( + 'https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token', + data={'grant_type': 'client_credentials', + 'client_id': os.environ['CUA_CLIENT_ID'], + 'client_secret': os.environ['CUA_CLIENT_SECRET']}, +).json()['access_token'] + +await run(tunnel.url + 'mcp', TASK, 'your-model', + headers={'Authorization': f'Bearer {token}'}) +``` + +That is the same `run()` as above, with only the URL and an auth header changed. A Fleet Windows sandbox takes about three minutes to become ready, against about thirty seconds for a warm local one, and `GET healthz` on that service URL answers `ok` when cua-driver is up. + +Notice there is no `-cpu host` here. That flag exists on the local path because the bare-metal runtime defaults to the thin `qemu64` model; a Fleet sandbox is provisioned for you and already reports a full host CPU — `Intel Xeon Processor (SapphireRapids)`, with AVX, AVX2 and AVX-512 all present — so there is nothing to override, and no QEMU arguments to pass. + +`Sandbox.create` also accepts `cpu` and `memory_mb`, but sizing a Fleet sandbox is account-dependent: passing them routes the request through a gated custom-resource path, which returned `403 create pool: k8s request is not allowed` at every size until a card-on-file requirement was lifted for the account. The default sandbox — 4 vCPU and 4 GB — is what this guide was written against, and it is enough. + + + **On Fleet the game needs one extra environment variable: `GALLIUM_DRIVER=softpipe`.** With Mesa's default llvmpipe renderer, Minecraft dies during resource loading every time — `Process crashed with exitcode -2147024809`, no Java exception, no `hs_err` file, nothing in the Windows event log, the log simply stopping after `Reloading ResourceManager`. Switching Mesa to its `softpipe` rasteriser fixes it, and the game runs. + + Set the variable in the process that launches the launcher, so the game inherits it — a machine-level variable does not reach an already-running process, and a test that silently did not apply looks exactly like a test that failed. `setup.ps1` above already started Prism without it, so close the running launcher first and start it again like this. + + ```powershell + $env:GALLIUM_DRIVER = 'softpipe' + Start-Process -FilePath C:\mc\prismw\prismlauncher.exe -WorkingDirectory C:\mc\prismw + ``` + + Softpipe is a reference rasteriser with no JIT, so it is **considerably slower than llvmpipe** — allow several minutes for the title screen and longer again for world generation. + + + + Use client credentials, not a `cua auth login` session token. The session token is short-lived and is held without refresh, so a long provisioning wait dies partway with `401 auth token is invalid`. Client-credential tokens expire too — the ones issued here came back with `expires_in` of 900 seconds — so a run longer than that has to re-mint the token and rebuild the MCP client. + + +What that crash is *not*, since each obvious explanation was tested and eliminated: not the Minecraft version or the Java/LWJGL generation (1.20.1 on Java 17 with LWJGL 3 and 1.12.2 on Java 8 with LWJGL 2 fail identically), not the heap (forcing Prism's auto-sized `-Xmx2717m` down to `-Xmx1024m` changed nothing), and not the size of the machine (the same local session disk rebooted with `-m 4096 -smp 4`, matching Fleet exactly, runs the game fine). Narrowing llvmpipe's vectors with `LP_NATIVE_VECTOR_WIDTH=128` did **not** help either, which argues the fault is not simply wide-vector code generation. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| QEMU refuses to start with `-cpu host` | no KVM or HVF — for example an x86_64 guest on Apple Silicon, which runs under TCG | use a host with hardware virtualisation, or the Fleet path | +| Launcher never appears, no error | MSVC build without the VC++ redistributable | use the MinGW portable build | +| Guest has an IP address but cannot resolve names | both user-mode NICs offered the same address | give the second NIC its own subnet | +| `GLFW error 65542: WGL: The driver does not appear to support OpenGL` | Mesa DLLs missing beside the `javaw.exe` actually in use, or the MSVC Mesa build failed to load | copy the MinGW Mesa DLLs into every `javaw.exe` directory | +| Game exits during resource loading with no Java exception, **local** | default `qemu64` CPU model | append `-cpu host` to `extra_args` | +| Game exits during resource loading with `exitcode -2147024809`, **Fleet** | Mesa's default llvmpipe renderer | set `GALLIUM_DRIVER=softpipe` in the process that starts the launcher, and restart the launcher if it is already running | +| `Permission denied: user policy: tool 'X' is not allowed` | cua-driver's YAML policy refuses that tool, which `list_tools()` advertises anyway on every driver released so far | use an allowed tool — `get_desktop_state` instead of `get_screen_size`, `list_windows` instead of `get_accessibility_tree` | +| Model replies with empty output on the first call | endpoint is streaming-only | issue `stream=True` and rebuild with `litellm.stream_chunk_builder` | From 1803e52d0c4a94d88787706cfe89eccb6dc723db Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 13 Aug 2026 20:56:48 +0200 Subject: [PATCH 002/117] fix(cua-driver): preserve named CLI sessions (#3144) * fix(cua-driver): preserve named CLI sessions * test(cua-driver): cover named CLI session isolation --- .../rust/crates/cua-driver/src/cli.rs | 38 ++++-- .../cua-driver/tests/daemon_required_test.rs | 124 ++++++++++++++++++ 2 files changed, 150 insertions(+), 12 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs index fa59231cae..7875e34569 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/cli.rs @@ -2037,7 +2037,19 @@ pub fn run_call( .clone() .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); cua_driver_core::tool_args::sanitize_reserved_args(&mut args_for_daemon); - let transport_session = format!("cli-{}", uuid::Uuid::new_v4()); + let named_session = args_for_daemon + .get("session") + .and_then(serde_json::Value::as_str) + .is_some_and(|session| !session.is_empty() && session != "default"); + // One-shot CLI processes share one daemon-scoped ownership namespace + // for explicit public labels. The daemon adds its runtime prefix, so + // this cannot attach to another daemon generation or transport kind. + // Anonymous calls keep their disposable per-process lease below. + let transport_session = if named_session { + "cli-explicit".to_owned() + } else { + format!("cli-{}", uuid::Uuid::new_v4()) + }; let req = crate::serve::DaemonRequest { method: "call".into(), name: Some(tool.to_owned()), @@ -2050,17 +2062,19 @@ pub fn run_call( client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), }; let response = crate::serve::send_request(&socket_path, &req); - let cleanup = crate::serve::DaemonRequest { - method: "session_end".into(), - name: None, - args: None, - session_id: Some(transport_session), - observation_origin: None, - client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), - }; - let cleanup_result = crate::serve::send_request(&socket_path, &cleanup); - if let Err(error) = cleanup_result { - eprintln!("warning: disposable session cleanup failed: {error}"); + if !named_session { + let cleanup = crate::serve::DaemonRequest { + method: "session_end".into(), + name: None, + args: None, + session_id: Some(transport_session), + observation_origin: None, + client_kind: Some(cua_driver_core::daemon::DaemonClientKind::Cli), + }; + let cleanup_result = crate::serve::send_request(&socket_path, &cleanup); + if let Err(error) = cleanup_result { + eprintln!("warning: disposable session cleanup failed: {error}"); + } } match response { Ok(resp) => { diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs index eca85409c7..a3c599003d 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/daemon_required_test.rs @@ -149,6 +149,130 @@ fn cli_call_succeeds_through_test_owned_daemon() { assert!(response.structured().is_object()); } +#[test] +fn named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-lifecycle-{}", std::process::id()); + + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "named session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let sessions = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list live sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 1); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); + + let sessions = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args([ + "sessions", + "list", + "--json", + "--socket", + driver.daemon_socket().expect("test daemon socket"), + ]) + .output() + .expect("list ended sessions"); + assert!( + sessions.status.success(), + "session list failed: {}", + String::from_utf8_lossy(&sessions.stderr) + ); + let sessions: serde_json::Value = + serde_json::from_slice(&sessions.stdout).expect("session list JSON"); + assert_eq!(sessions["count"], 0); +} + +#[test] +fn implicitly_started_named_session_survives_across_one_shot_cli_calls() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let session = format!("synthetic-cli-implicit-{}", std::process::id()); + + let first_action = driver.call("get_config", serde_json::json!({"session": session})); + assert!( + !first_action.is_error(), + "implicit first action failed: {}", + first_action.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": session})); + assert!( + !state.is_error(), + "implicitly started session did not survive the next CLI process: {}", + state.text() + ); + assert_eq!(state.structured()["session"], session); + + let ended = driver.call("end_session", serde_json::json!({"session": session})); + assert!(!ended.is_error(), "end failed: {}", ended.text()); +} + +#[test] +fn named_cli_session_cleanup_is_isolated() { + let mut driver = CliDriver::new(); + assert!(driver.available(), "test daemon failed to start"); + let first = format!("synthetic-cli-isolation-a-{}", std::process::id()); + let second = format!("synthetic-cli-isolation-b-{}", std::process::id()); + + for session in [&first, &second] { + let started = driver.call( + "start_session", + serde_json::json!({"session": session, "capture_scope": "window"}), + ); + assert!(!started.is_error(), "start failed: {}", started.text()); + } + + let ended = driver.call("end_session", serde_json::json!({"session": first})); + assert!(!ended.is_error(), "first end failed: {}", ended.text()); + + let anonymous = driver.call("get_config", serde_json::json!({})); + assert!( + !anonymous.is_error(), + "anonymous one-shot call failed: {}", + anonymous.text() + ); + + let state = driver.call("get_session_state", serde_json::json!({"session": second})); + assert!( + !state.is_error(), + "ending another named session or cleaning an anonymous call ended the survivor: {}", + state.text() + ); + assert_eq!(state.structured()["session"], second); + + let ended = driver.call("end_session", serde_json::json!({"session": second})); + assert!(!ended.is_error(), "second end failed: {}", ended.text()); +} + #[test] fn revoke_cli_ends_the_exact_live_session() { let mut driver = CliDriver::new(); From 54d62e72ef449363103c588a088400c7aa5538d1 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Wed, 12 Aug 2026 22:14:53 +0000 Subject: [PATCH 003/117] feat(cyclops): add browser bash chat (#6769) * feat(cyclops): add browser bash chat * fix(cyclops): finalize browser bash chat * test(cyclops): update chat SDK import * fix(cyclops): harden browser chat previews * fix(cyclops): bound in-memory chat storage * fix(cyclops): render previews from PR commits CloudCyclopsCs-RevId: 40f89a33057b364a71b2a9afb945cb70ef362a02 --- libs/fleet/agent.test.ts | 206 ++++++ libs/fleet/backend/auth/authz_chat.rego | 23 + libs/fleet/backend/auth/authz_chat_test.rego | 35 + libs/fleet/backend/auth/middlewares.go | 4 + .../auth/policy_characterization_test.go | 7 + libs/fleet/backend/auth/policy_routes.go | 10 + .../backend/auth/testdata/chat-route-plan.txt | 3 + .../testdata/route-authorization-table.txt | 165 +++++ libs/fleet/backend/chat/litellm.go | 343 +++++++++ libs/fleet/backend/chat/litellm_test.go | 265 +++++++ libs/fleet/backend/chat/store.go | 207 ++++++ libs/fleet/backend/chat/store_test.go | 237 +++++++ libs/fleet/backend/chat/types.go | 73 ++ libs/fleet/backend/config/config.go | 21 + libs/fleet/backend/config/config_test.go | 52 ++ libs/fleet/backend/docs/docs.go | 343 +++++++++ libs/fleet/backend/docs/swagger.json | 343 +++++++++ libs/fleet/backend/docs/swagger.yaml | 220 ++++++ libs/fleet/backend/handlers/chat.go | 446 ++++++++++++ libs/fleet/backend/handlers/chat_locks.go | 48 ++ libs/fleet/backend/handlers/chat_test.go | 520 ++++++++++++++ libs/fleet/backend/handlers/config.go | 3 +- libs/fleet/backend/handlers/handlers.go | 8 + libs/fleet/backend/main.go | 16 + libs/fleet/backend/main_test.go | 31 + libs/fleet/docs/browser-agent.md | 153 ++++ libs/fleet/e2e/agent-chat.spec.ts | 636 +++++++++++++++++ libs/fleet/e2e/fixtures/mock-api.ts | 268 ++++++- libs/fleet/nginx.conf | 2 +- libs/fleet/package.json | 12 +- libs/fleet/pnpm-lock.yaml | 664 +++++++++++++++++- libs/fleet/src/App.tsx | 12 +- libs/fleet/src/browser-agent.ts | 218 ++++++ .../src/components/FeatureFlagContext.tsx | 2 +- libs/fleet/src/node-zlib-browser.ts | 14 + libs/fleet/src/pages/AgentChat.css | 163 +++++ libs/fleet/src/pages/AgentChat.tsx | 558 +++++++++++++++ libs/fleet/src/sdk/chat.ts | 207 ++++++ libs/fleet/src/sdk/featureFlags.ts | 4 + libs/fleet/vite.config.ts | 6 + 40 files changed, 6534 insertions(+), 14 deletions(-) create mode 100644 libs/fleet/agent.test.ts create mode 100644 libs/fleet/backend/auth/authz_chat.rego create mode 100644 libs/fleet/backend/auth/authz_chat_test.rego create mode 100644 libs/fleet/backend/auth/testdata/chat-route-plan.txt create mode 100644 libs/fleet/backend/chat/litellm.go create mode 100644 libs/fleet/backend/chat/litellm_test.go create mode 100644 libs/fleet/backend/chat/store.go create mode 100644 libs/fleet/backend/chat/store_test.go create mode 100644 libs/fleet/backend/chat/types.go create mode 100644 libs/fleet/backend/handlers/chat.go create mode 100644 libs/fleet/backend/handlers/chat_locks.go create mode 100644 libs/fleet/backend/handlers/chat_test.go create mode 100644 libs/fleet/docs/browser-agent.md create mode 100644 libs/fleet/e2e/agent-chat.spec.ts create mode 100644 libs/fleet/src/browser-agent.ts create mode 100644 libs/fleet/src/node-zlib-browser.ts create mode 100644 libs/fleet/src/pages/AgentChat.css create mode 100644 libs/fleet/src/pages/AgentChat.tsx create mode 100644 libs/fleet/src/sdk/chat.ts diff --git a/libs/fleet/agent.test.ts b/libs/fleet/agent.test.ts new file mode 100644 index 0000000000..b13872dbe2 --- /dev/null +++ b/libs/fleet/agent.test.ts @@ -0,0 +1,206 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + BrowserBashAgent, + normalizeBashArguments, + sanitizeError, + type AssistantMessage, + type AgentEvent, + type TurnClient, +} from "./src/browser-agent.ts" +import { gzipSync, gunzipSync } from "./src/node-zlib-browser.ts" + +test("clamps model-selected bash limits", () => { + assert.deepEqual( + normalizeBashArguments({ command: "echo ok", timeout_ms: 1, max_output_chars: 9_999_999 }), + { command: "echo ok", timeout_ms: 250, max_output_chars: 100_000 }, + ) +}) + +test("sanitizes control characters in bash execution errors", () => { + assert.equal(sanitizeError(new Error("\u0000failed\nwith\u007fcontrols\u0085")), "failed with controls") + assert.equal(sanitizeError(new Error("\u0000\u001f\u007f\u009f")), "Bash execution failed") +}) + +test("keeps one virtual filesystem per conversation", async () => { + const unused: TurnClient = async () => ({ role: "assistant", content: "done", tool_calls: [] }) + const agent = new BrowserBashAgent(unused) + await agent.executeBash("one", { command: "echo alpha > note.txt" }) + assert.equal((await agent.executeBash("one", { command: "cat note.txt" })).stdout, "alpha\n") + assert.notEqual((await agent.executeBash("two", { command: "cat note.txt" })).exit_code, 0) +}) + +test("browser zlib shim fails only when gzip-backed commands execute", async () => { + const agent = new BrowserBashAgent(async () => ({ role: "assistant", content: "", tool_calls: [] })) + assert.equal((await agent.executeBash("zlib-shim", { command: "printf normal" })).stdout, "normal") + + assert.throws(() => gzipSync(new Uint8Array()), /gzip commands are unsupported in the browser/) + assert.throws(() => gunzipSync(new Uint8Array()), /gzip commands are unsupported in the browser/) +}) + +test("runs tool calls until the assistant finishes", async () => { + const requests: Parameters[1][] = [] + const responses: AssistantMessage[] = [ + { + role: "assistant", + content: "", + tool_calls: [{ id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"printf hello"}' } }], + }, + { role: "assistant", content: "The command printed hello.", tool_calls: [] }, + ] + const client: TurnClient = async (_id, messages, _signal, onDelta) => { + requests.push(messages) + onDelta?.(requests.length === 1 ? "Running bash." : "Bash finished.") + return responses.shift()! + } + const events: AgentEvent[] = [] + const agent = new BrowserBashAgent(client) + await agent.run("conversation-1", [{ role: "user", content: "Run it" }], event => events.push(event)) + + assert.deepEqual(requests, [ + [{ role: "user", content: "Run it" }], + [ + { + role: "tool", + tool_call_id: "call-1", + content: JSON.stringify({ stdout: "hello", stderr: "", exit_code: 0, timed_out: false, truncated: false }), + }, + ], + ]) + assert.deepEqual( + events.map(event => event.type), + ["generation_start", "assistant_delta", "tool_start", "tool_result", "generation_start", "assistant_delta", "complete"], + ) + assert.deepEqual(events[1], { type: "assistant_delta", delta: "Running bash." }) + assert.deepEqual(events[5], { type: "assistant_delta", delta: "Bash finished." }) +}) + +test("normalizes timeout and truncation results", async () => { + const agent = new BrowserBashAgent(async () => ({ role: "assistant", content: "", tool_calls: [] })) + const truncated = await agent.executeBash("one", { command: "printf 123456", max_output_chars: 4 }) + assert.deepEqual(truncated, { stdout: "1234", stderr: "", exit_code: 0, timed_out: false, truncated: true }) + + const maximum = await agent.executeBash("one", { + command: `printf ${"x".repeat(100_001)}`, + max_output_chars: 100_001, + }) + assert.equal(maximum.stdout.length + maximum.stderr.length, 100_000) + assert.equal(maximum.truncated, true) + + const timedOut = await agent.executeBash("one", { command: "sleep 2", timeout_ms: 250 }) + assert.equal(timedOut.exit_code, 124) + assert.equal(timedOut.timed_out, true) +}) + +test("caller cancellation aborts bash execution and stops the tool loop", async () => { + let requests = 0 + const controller = new AbortController() + const events: AgentEvent[] = [] + const agent = new BrowserBashAgent(async () => { + requests += 1 + return { + role: "assistant", + content: "", + tool_calls: [ + { id: "call-cancel", type: "function", function: { name: "bash", arguments: '{"command":"sleep 2"}' } }, + ], + } + }) + + const run = agent.run( + "conversation-cancel", + [{ role: "user", content: "Run it" }], + event => { + events.push(event) + if (event.type === "tool_start") { + controller.abort() + } + }, + controller.signal, + ) + + await assert.rejects(run, { name: "AbortError" }) + assert.equal(requests, 1) + assert.deepEqual(events.map(event => event.type), ["generation_start", "tool_start"]) +}) + +test("batches multiple bash tool results into one follow-up turn", async () => { + const requests: Parameters[1][] = [] + const responses: AssistantMessage[] = [ + { + role: "assistant", + content: "", + tool_calls: [ + { id: "call-left", type: "function", function: { name: "bash", arguments: '{"command":"printf left"}' } }, + { id: "call-right", type: "function", function: { name: "bash", arguments: '{"command":"printf right"}' } }, + ], + }, + { role: "assistant", content: "Both commands completed.", tool_calls: [] }, + ] + const client: TurnClient = async (_id, messages) => { + requests.push(messages) + return responses.shift()! + } + const events: AgentEvent[] = [] + const agent = new BrowserBashAgent(client) + + await agent.run("conversation-batch", [{ role: "user", content: "Run both commands" }], event => events.push(event)) + + assert.equal(requests.length, 2) + assert.deepEqual(requests[1], [ + { + role: "tool", + tool_call_id: "call-left", + content: JSON.stringify({ stdout: "left", stderr: "", exit_code: 0, timed_out: false, truncated: false }), + }, + { + role: "tool", + tool_call_id: "call-right", + content: JSON.stringify({ stdout: "right", stderr: "", exit_code: 0, timed_out: false, truncated: false }), + }, + ]) + assert.deepEqual( + events.map(event => event.type), + ["generation_start", "tool_start", "tool_start", "tool_result", "tool_result", "generation_start", "complete"], + ) + assert.deepEqual( + events + .filter((event): event is Extract => event.type === "tool_start") + .map(event => event.toolCall.id), + ["call-left", "call-right"], + ) + assert.deepEqual( + events + .filter((event): event is Extract => event.type === "tool_result") + .map(event => ({ id: event.toolCall.id, result: event.result })) + .sort((left, right) => left.id.localeCompare(right.id)), + [ + { id: "call-left", result: { stdout: "left", stderr: "", exit_code: 0, timed_out: false, truncated: false } }, + { id: "call-right", result: { stdout: "right", stderr: "", exit_code: 0, timed_out: false, truncated: false } }, + ], + ) +}) + +test("rejects mixed tool batches before bash side effects", async () => { + const events: AgentEvent[] = [] + const agent = new BrowserBashAgent(async () => ({ + role: "assistant", + content: "", + tool_calls: [ + { + id: "call-create-file", + type: "function", + function: { name: "bash", arguments: '{"command":"touch should-not-exist"}' }, + }, + { id: "call-unsupported", type: "function", function: { name: "other", arguments: "{}" } }, + ], + })) + + await assert.rejects( + agent.run("conversation-mixed", [{ role: "user", content: "Run the tools" }], event => events.push(event)), + { message: "Unsupported tool: other" }, + ) + + assert.deepEqual(events.map(event => event.type), ["generation_start"]) + assert.notEqual((await agent.executeBash("conversation-mixed", { command: "test -e should-not-exist" })).exit_code, 0) +}) diff --git a/libs/fleet/backend/auth/authz_chat.rego b/libs/fleet/backend/auth/authz_chat.rego new file mode 100644 index 0000000000..f37b49d2b5 --- /dev/null +++ b/libs/fleet/backend/auth/authz_chat.rego @@ -0,0 +1,23 @@ +# Browser-local Bash chat. The backend stores history and proxies LiteLLM, but +# the tool itself runs in the authenticated SPA. Keep service-account and +# per-key credentials off this browser surface. +package authz_chat + +import data.authz + +default allow = false + +allow { + input.route == "/api/chat/conversations" + input.user.azp == "cyclops-cs-spa" +} + +allow { + input.route == "/api/chat/conversations/{id}" + input.user.azp == "cyclops-cs-spa" +} + +allow { + input.route == "/api/chat/conversations/{id}/turns" + input.user.azp == "cyclops-cs-spa" +} diff --git a/libs/fleet/backend/auth/authz_chat_test.rego b/libs/fleet/backend/auth/authz_chat_test.rego new file mode 100644 index 0000000000..6db484a72b --- /dev/null +++ b/libs/fleet/backend/auth/authz_chat_test.rego @@ -0,0 +1,35 @@ +package authz_chat_test + +import rego.v1 + +import data.authz_base +import data.authz_chat + +route_allow if { + authz_base.allow + authz_chat.allow +} + +chat_request(route, azp, sub) := { + "route": route, + "method": "GET", + "path": route, + "params": {}, + "user": {"sub": sub, "azp": azp, "namespace": "", "email": "u@example.com"}, +} + +test_spa_chat_routes_allowed if { + every route in [ + "/api/chat/conversations", + "/api/chat/conversations/{id}", + "/api/chat/conversations/{id}/turns", + ] { + route_allow with input as chat_request(route, "cyclops-cs-spa", "user-123") + } +} + +test_non_spa_chat_routes_denied if { + every azp in ["cua-cli", "ukey-user", "key-pool"] { + not route_allow with input as chat_request("/api/chat/conversations", azp, "user-123") + } +} diff --git a/libs/fleet/backend/auth/middlewares.go b/libs/fleet/backend/auth/middlewares.go index 40ae16b243..7285439cb3 100644 --- a/libs/fleet/backend/auth/middlewares.go +++ b/libs/fleet/backend/auth/middlewares.go @@ -56,6 +56,7 @@ var surfacePolicySources = map[string]struct { "authz-base": {"authz_base.rego", authzBasePolicy}, "authz-keys": {"authz_keys.rego", authzKeysPolicy}, "authz-config": {"authz_config.rego", authzConfigPolicy}, + "authz-chat": {"authz_chat.rego", authzChatPolicy}, "authz-billing": {"authz_billing.rego", authzBillingPolicy}, "authz-namespaces": {"authz_namespaces.rego", authzNamespacesPolicy}, "authz-github-trust": {"authz_github_trust.rego", authzGitHubTrustPolicy}, @@ -75,6 +76,9 @@ var authzKeysPolicy string //go:embed authz_config.rego var authzConfigPolicy string +//go:embed authz_chat.rego +var authzChatPolicy string + //go:embed authz_billing.rego var authzBillingPolicy string diff --git a/libs/fleet/backend/auth/policy_characterization_test.go b/libs/fleet/backend/auth/policy_characterization_test.go index 83c87affe3..e866a14bd2 100644 --- a/libs/fleet/backend/auth/policy_characterization_test.go +++ b/libs/fleet/backend/auth/policy_characterization_test.go @@ -159,6 +159,7 @@ func characterizationCases() map[string][]routeCase { } simple("/api/config", "/api/config") simple("/api/state/query", "/api/state/query") + simple("/api/chat/conversations", "/api/chat/conversations") simple("/api/billing/summary", "/api/billing/summary") simple("/api/billing/setup-session", "/api/billing/setup-session") simple("/api/billing/portal-session", "/api/billing/portal-session") @@ -175,8 +176,14 @@ func characterizationCases() map[string][]routeCase { }} } withID("/api/keys/{id}", "/api/keys") + withID("/api/chat/conversations/{id}", "/api/chat/conversations") withID("/api/user-keys/{id}", "/api/user-keys") withID("/api/github-trust-policies/{id}", "/api/github-trust-policies") + cases["/api/chat/conversations/{id}/turns"] = []routeCase{{ + name: "id", + params: map[string]string{"id": "id-1"}, + path: "/api/chat/conversations/id-1/turns", + }} // /api/namespaces/{name} needs all three fact answers, and only on GET: the // ownership conjunct binds to that one method, and DELETE sharing the route diff --git a/libs/fleet/backend/auth/policy_routes.go b/libs/fleet/backend/auth/policy_routes.go index 5ecc843f91..3aeb09b56c 100644 --- a/libs/fleet/backend/auth/policy_routes.go +++ b/libs/fleet/backend/auth/policy_routes.go @@ -68,6 +68,11 @@ func ConfigRoutePolicy() Node { return All(BasePolicy(), surfaceLeaf("authz-config", "data.authz_config.allow")) } +// ChatRoutePolicy guards browser-local Bash conversation routes. +func ChatRoutePolicy() Node { + return All(BasePolicy(), surfaceLeaf("authz-chat", "data.authz_chat.allow")) +} + // BillingRoutePolicy guards the Stripe-hosted billing browser routes. Its module // matches a prefix rather than three literals, so a billing route added to // main.go and bound here is covered without a policy change. @@ -224,6 +229,7 @@ type surfacePolicy struct { var surfacePolicies = map[string]surfacePolicy{ "keys": {tree: KeysRoutePolicy}, "config": {tree: ConfigRoutePolicy}, + "chat": {tree: ChatRoutePolicy}, "billing": {tree: BillingRoutePolicy}, "namespaces": {tree: NamespacesRoutePolicy}, "github-trust": {tree: GitHubTrustRoutePolicy}, @@ -248,6 +254,10 @@ var routeSurfaces = map[string]string{ "/api/config": "config", "/api/state/query": "state-query", + "/api/chat/conversations": "chat", + "/api/chat/conversations/{id}": "chat", + "/api/chat/conversations/{id}/turns": "chat", + "/api/billing/summary": "billing", "/api/billing/setup-session": "billing", "/api/billing/portal-session": "billing", diff --git a/libs/fleet/backend/auth/testdata/chat-route-plan.txt b/libs/fleet/backend/auth/testdata/chat-route-plan.txt new file mode 100644 index 0000000000..8c0e271fc2 --- /dev/null +++ b/libs/fleet/backend/auth/testdata/chat-route-plan.txt @@ -0,0 +1,3 @@ +All + Leaf query=data.authz_base.allow source=registered:authz-base + Leaf query=data.authz_chat.allow source=multi["registered:authz","registered:authz-chat"] diff --git a/libs/fleet/backend/auth/testdata/route-authorization-table.txt b/libs/fleet/backend/auth/testdata/route-authorization-table.txt index 41ad947db2..2970a96e2f 100644 --- a/libs/fleet/backend/auth/testdata/route-authorization-table.txt +++ b/libs/fleet/backend/auth/testdata/route-authorization-table.txt @@ -163,6 +163,171 @@ /api/billing/summary | plain | QUERY | spa = allow /api/billing/summary | plain | QUERY | user-key = deny /api/billing/summary | plain | QUERY | user-key-verified = deny +/api/chat/conversations | plain | DELETE | admin-spa = allow +/api/chat/conversations | plain | DELETE | cua-cli = deny +/api/chat/conversations | plain | DELETE | dcr-client = deny +/api/chat/conversations | plain | DELETE | empty-sub = deny +/api/chat/conversations | plain | DELETE | github-oidc = deny +/api/chat/conversations | plain | DELETE | oauth2-proxy = deny +/api/chat/conversations | plain | DELETE | per-key-other-ns = deny +/api/chat/conversations | plain | DELETE | per-key-owned-ns = deny +/api/chat/conversations | plain | DELETE | spa = allow +/api/chat/conversations | plain | DELETE | user-key = deny +/api/chat/conversations | plain | DELETE | user-key-verified = deny +/api/chat/conversations | plain | GET | admin-spa = allow +/api/chat/conversations | plain | GET | cua-cli = deny +/api/chat/conversations | plain | GET | dcr-client = deny +/api/chat/conversations | plain | GET | empty-sub = deny +/api/chat/conversations | plain | GET | github-oidc = deny +/api/chat/conversations | plain | GET | oauth2-proxy = deny +/api/chat/conversations | plain | GET | per-key-other-ns = deny +/api/chat/conversations | plain | GET | per-key-owned-ns = deny +/api/chat/conversations | plain | GET | spa = allow +/api/chat/conversations | plain | GET | user-key = deny +/api/chat/conversations | plain | GET | user-key-verified = deny +/api/chat/conversations | plain | PATCH | admin-spa = allow +/api/chat/conversations | plain | PATCH | cua-cli = deny +/api/chat/conversations | plain | PATCH | dcr-client = deny +/api/chat/conversations | plain | PATCH | empty-sub = deny +/api/chat/conversations | plain | PATCH | github-oidc = deny +/api/chat/conversations | plain | PATCH | oauth2-proxy = deny +/api/chat/conversations | plain | PATCH | per-key-other-ns = deny +/api/chat/conversations | plain | PATCH | per-key-owned-ns = deny +/api/chat/conversations | plain | PATCH | spa = allow +/api/chat/conversations | plain | PATCH | user-key = deny +/api/chat/conversations | plain | PATCH | user-key-verified = deny +/api/chat/conversations | plain | POST | admin-spa = allow +/api/chat/conversations | plain | POST | cua-cli = deny +/api/chat/conversations | plain | POST | dcr-client = deny +/api/chat/conversations | plain | POST | empty-sub = deny +/api/chat/conversations | plain | POST | github-oidc = deny +/api/chat/conversations | plain | POST | oauth2-proxy = deny +/api/chat/conversations | plain | POST | per-key-other-ns = deny +/api/chat/conversations | plain | POST | per-key-owned-ns = deny +/api/chat/conversations | plain | POST | spa = allow +/api/chat/conversations | plain | POST | user-key = deny +/api/chat/conversations | plain | POST | user-key-verified = deny +/api/chat/conversations | plain | QUERY | admin-spa = allow +/api/chat/conversations | plain | QUERY | cua-cli = deny +/api/chat/conversations | plain | QUERY | dcr-client = deny +/api/chat/conversations | plain | QUERY | empty-sub = deny +/api/chat/conversations | plain | QUERY | github-oidc = deny +/api/chat/conversations | plain | QUERY | oauth2-proxy = deny +/api/chat/conversations | plain | QUERY | per-key-other-ns = deny +/api/chat/conversations | plain | QUERY | per-key-owned-ns = deny +/api/chat/conversations | plain | QUERY | spa = allow +/api/chat/conversations | plain | QUERY | user-key = deny +/api/chat/conversations | plain | QUERY | user-key-verified = deny +/api/chat/conversations/{id} | id | DELETE | admin-spa = allow +/api/chat/conversations/{id} | id | DELETE | cua-cli = deny +/api/chat/conversations/{id} | id | DELETE | dcr-client = deny +/api/chat/conversations/{id} | id | DELETE | empty-sub = deny +/api/chat/conversations/{id} | id | DELETE | github-oidc = deny +/api/chat/conversations/{id} | id | DELETE | oauth2-proxy = deny +/api/chat/conversations/{id} | id | DELETE | per-key-other-ns = deny +/api/chat/conversations/{id} | id | DELETE | per-key-owned-ns = deny +/api/chat/conversations/{id} | id | DELETE | spa = allow +/api/chat/conversations/{id} | id | DELETE | user-key = deny +/api/chat/conversations/{id} | id | DELETE | user-key-verified = deny +/api/chat/conversations/{id} | id | GET | admin-spa = allow +/api/chat/conversations/{id} | id | GET | cua-cli = deny +/api/chat/conversations/{id} | id | GET | dcr-client = deny +/api/chat/conversations/{id} | id | GET | empty-sub = deny +/api/chat/conversations/{id} | id | GET | github-oidc = deny +/api/chat/conversations/{id} | id | GET | oauth2-proxy = deny +/api/chat/conversations/{id} | id | GET | per-key-other-ns = deny +/api/chat/conversations/{id} | id | GET | per-key-owned-ns = deny +/api/chat/conversations/{id} | id | GET | spa = allow +/api/chat/conversations/{id} | id | GET | user-key = deny +/api/chat/conversations/{id} | id | GET | user-key-verified = deny +/api/chat/conversations/{id} | id | PATCH | admin-spa = allow +/api/chat/conversations/{id} | id | PATCH | cua-cli = deny +/api/chat/conversations/{id} | id | PATCH | dcr-client = deny +/api/chat/conversations/{id} | id | PATCH | empty-sub = deny +/api/chat/conversations/{id} | id | PATCH | github-oidc = deny +/api/chat/conversations/{id} | id | PATCH | oauth2-proxy = deny +/api/chat/conversations/{id} | id | PATCH | per-key-other-ns = deny +/api/chat/conversations/{id} | id | PATCH | per-key-owned-ns = deny +/api/chat/conversations/{id} | id | PATCH | spa = allow +/api/chat/conversations/{id} | id | PATCH | user-key = deny +/api/chat/conversations/{id} | id | PATCH | user-key-verified = deny +/api/chat/conversations/{id} | id | POST | admin-spa = allow +/api/chat/conversations/{id} | id | POST | cua-cli = deny +/api/chat/conversations/{id} | id | POST | dcr-client = deny +/api/chat/conversations/{id} | id | POST | empty-sub = deny +/api/chat/conversations/{id} | id | POST | github-oidc = deny +/api/chat/conversations/{id} | id | POST | oauth2-proxy = deny +/api/chat/conversations/{id} | id | POST | per-key-other-ns = deny +/api/chat/conversations/{id} | id | POST | per-key-owned-ns = deny +/api/chat/conversations/{id} | id | POST | spa = allow +/api/chat/conversations/{id} | id | POST | user-key = deny +/api/chat/conversations/{id} | id | POST | user-key-verified = deny +/api/chat/conversations/{id} | id | QUERY | admin-spa = allow +/api/chat/conversations/{id} | id | QUERY | cua-cli = deny +/api/chat/conversations/{id} | id | QUERY | dcr-client = deny +/api/chat/conversations/{id} | id | QUERY | empty-sub = deny +/api/chat/conversations/{id} | id | QUERY | github-oidc = deny +/api/chat/conversations/{id} | id | QUERY | oauth2-proxy = deny +/api/chat/conversations/{id} | id | QUERY | per-key-other-ns = deny +/api/chat/conversations/{id} | id | QUERY | per-key-owned-ns = deny +/api/chat/conversations/{id} | id | QUERY | spa = allow +/api/chat/conversations/{id} | id | QUERY | user-key = deny +/api/chat/conversations/{id} | id | QUERY | user-key-verified = deny +/api/chat/conversations/{id}/turns | id | DELETE | admin-spa = allow +/api/chat/conversations/{id}/turns | id | DELETE | cua-cli = deny +/api/chat/conversations/{id}/turns | id | DELETE | dcr-client = deny +/api/chat/conversations/{id}/turns | id | DELETE | empty-sub = deny +/api/chat/conversations/{id}/turns | id | DELETE | github-oidc = deny +/api/chat/conversations/{id}/turns | id | DELETE | oauth2-proxy = deny +/api/chat/conversations/{id}/turns | id | DELETE | per-key-other-ns = deny +/api/chat/conversations/{id}/turns | id | DELETE | per-key-owned-ns = deny +/api/chat/conversations/{id}/turns | id | DELETE | spa = allow +/api/chat/conversations/{id}/turns | id | DELETE | user-key = deny +/api/chat/conversations/{id}/turns | id | DELETE | user-key-verified = deny +/api/chat/conversations/{id}/turns | id | GET | admin-spa = allow +/api/chat/conversations/{id}/turns | id | GET | cua-cli = deny +/api/chat/conversations/{id}/turns | id | GET | dcr-client = deny +/api/chat/conversations/{id}/turns | id | GET | empty-sub = deny +/api/chat/conversations/{id}/turns | id | GET | github-oidc = deny +/api/chat/conversations/{id}/turns | id | GET | oauth2-proxy = deny +/api/chat/conversations/{id}/turns | id | GET | per-key-other-ns = deny +/api/chat/conversations/{id}/turns | id | GET | per-key-owned-ns = deny +/api/chat/conversations/{id}/turns | id | GET | spa = allow +/api/chat/conversations/{id}/turns | id | GET | user-key = deny +/api/chat/conversations/{id}/turns | id | GET | user-key-verified = deny +/api/chat/conversations/{id}/turns | id | PATCH | admin-spa = allow +/api/chat/conversations/{id}/turns | id | PATCH | cua-cli = deny +/api/chat/conversations/{id}/turns | id | PATCH | dcr-client = deny +/api/chat/conversations/{id}/turns | id | PATCH | empty-sub = deny +/api/chat/conversations/{id}/turns | id | PATCH | github-oidc = deny +/api/chat/conversations/{id}/turns | id | PATCH | oauth2-proxy = deny +/api/chat/conversations/{id}/turns | id | PATCH | per-key-other-ns = deny +/api/chat/conversations/{id}/turns | id | PATCH | per-key-owned-ns = deny +/api/chat/conversations/{id}/turns | id | PATCH | spa = allow +/api/chat/conversations/{id}/turns | id | PATCH | user-key = deny +/api/chat/conversations/{id}/turns | id | PATCH | user-key-verified = deny +/api/chat/conversations/{id}/turns | id | POST | admin-spa = allow +/api/chat/conversations/{id}/turns | id | POST | cua-cli = deny +/api/chat/conversations/{id}/turns | id | POST | dcr-client = deny +/api/chat/conversations/{id}/turns | id | POST | empty-sub = deny +/api/chat/conversations/{id}/turns | id | POST | github-oidc = deny +/api/chat/conversations/{id}/turns | id | POST | oauth2-proxy = deny +/api/chat/conversations/{id}/turns | id | POST | per-key-other-ns = deny +/api/chat/conversations/{id}/turns | id | POST | per-key-owned-ns = deny +/api/chat/conversations/{id}/turns | id | POST | spa = allow +/api/chat/conversations/{id}/turns | id | POST | user-key = deny +/api/chat/conversations/{id}/turns | id | POST | user-key-verified = deny +/api/chat/conversations/{id}/turns | id | QUERY | admin-spa = allow +/api/chat/conversations/{id}/turns | id | QUERY | cua-cli = deny +/api/chat/conversations/{id}/turns | id | QUERY | dcr-client = deny +/api/chat/conversations/{id}/turns | id | QUERY | empty-sub = deny +/api/chat/conversations/{id}/turns | id | QUERY | github-oidc = deny +/api/chat/conversations/{id}/turns | id | QUERY | oauth2-proxy = deny +/api/chat/conversations/{id}/turns | id | QUERY | per-key-other-ns = deny +/api/chat/conversations/{id}/turns | id | QUERY | per-key-owned-ns = deny +/api/chat/conversations/{id}/turns | id | QUERY | spa = allow +/api/chat/conversations/{id}/turns | id | QUERY | user-key = deny +/api/chat/conversations/{id}/turns | id | QUERY | user-key-verified = deny /api/config | plain | DELETE | admin-spa = allow /api/config | plain | DELETE | cua-cli = allow /api/config | plain | DELETE | dcr-client = deny diff --git a/libs/fleet/backend/chat/litellm.go b/libs/fleet/backend/chat/litellm.go new file mode 100644 index 0000000000..1c86a652d5 --- /dev/null +++ b/libs/fleet/backend/chat/litellm.go @@ -0,0 +1,343 @@ +package chat + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" +) + +const ( + bashToolDescription = "Execute a command in a temporary, isolated in-browser virtual filesystem. Network and host filesystem access are unavailable." + liteLLMResponseMaxBytes = 128 << 10 +) + +// ModelClient produces one assistant response while streaming content deltas. +type ModelClient interface { + Complete(ctx context.Context, messages []Message, onDelta func(string) error) (Message, error) +} + +type LiteLLMClient struct { + BaseURL string + APIKey string + Model string + HTTPClient *http.Client +} + +func NewLiteLLMClient(baseURL, apiKey, model string) *LiteLLMClient { + return &LiteLLMClient{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + HTTPClient: http.DefaultClient, + } +} + +type liteLLMRequest struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []liteLLMMessage `json:"messages"` + Tools []liteLLMTool `json:"tools"` +} + +type liteLLMMessage struct { + Role Role `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +type liteLLMTool struct { + Type string `json:"type"` + Function liteLLMToolFunction `json:"function"` +} + +type liteLLMToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters liteLLMToolParameters `json:"parameters"` +} + +type liteLLMToolParameters struct { + Type string `json:"type"` + AdditionalProperties bool `json:"additionalProperties"` + Properties map[string]liteLLMToolProperty `json:"properties"` + Required []string `json:"required"` +} + +type liteLLMToolProperty struct { + Type string `json:"type"` + Minimum *int `json:"minimum,omitempty"` + Maximum *int `json:"maximum,omitempty"` +} + +type liteLLMChunk struct { + Choices []liteLLMChoice `json:"choices"` +} + +type liteLLMChoice struct { + Delta *liteLLMDelta `json:"delta"` +} + +type liteLLMDelta struct { + Content string `json:"content"` + ToolCalls []liteLLMToolCallDelta `json:"tool_calls"` +} + +type liteLLMToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id"` + Type string `json:"type"` + Function liteLLMFunctionDelta `json:"function"` +} + +type liteLLMFunctionDelta struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +func (client *LiteLLMClient) Complete(ctx context.Context, messages []Message, onDelta func(string) error) (Message, error) { + requestBody, err := json.Marshal(liteLLMRequest{ + Model: client.Model, + Stream: true, + Messages: liteLLMMessages(messages), + Tools: []liteLLMTool{bashTool()}, + }) + if err != nil { + return Message{}, fmt.Errorf("marshal LiteLLM request: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(client.BaseURL, "/")+"/chat/completions", strings.NewReader(string(requestBody))) + if err != nil { + return Message{}, fmt.Errorf("create LiteLLM request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + if client.APIKey != "" { + request.Header.Set("Authorization", "Bearer "+client.APIKey) + } + + httpClient := client.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + response, err := httpClient.Do(request) + if err != nil { + if ctx.Err() != nil { + return Message{}, ctx.Err() + } + return Message{}, fmt.Errorf("send LiteLLM request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return Message{}, liteLLMResponseError(response) + } + + var content strings.Builder + toolCalls := make(map[int]ToolCall) + done := false + reader := bufio.NewReader(response.Body) + for { + data, err := readSSEData(reader) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + if ctx.Err() != nil { + return Message{}, ctx.Err() + } + return Message{}, fmt.Errorf("read LiteLLM stream: %w", err) + } + if err := ctx.Err(); err != nil { + return Message{}, err + } + if strings.TrimSpace(data) == "[DONE]" { + done = true + break + } + + var chunk liteLLMChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + return Message{}, fmt.Errorf("parse SSE chunk: %w", err) + } + if len(chunk.Choices) != 1 || chunk.Choices[0].Delta == nil { + return Message{}, errors.New("unsupported LiteLLM SSE response shape") + } + if err := ctx.Err(); err != nil { + return Message{}, err + } + + delta := chunk.Choices[0].Delta + if delta.Content != "" { + if content.Len()+len(delta.Content) > liteLLMResponseMaxBytes { + return Message{}, errors.New("LiteLLM response exceeds size limit") + } + if onDelta != nil { + if err := onDelta(delta.Content); err != nil { + return Message{}, fmt.Errorf("handle content delta: %w", err) + } + if err := ctx.Err(); err != nil { + return Message{}, err + } + } + content.WriteString(delta.Content) + } + for _, toolCallDelta := range delta.ToolCalls { + if toolCallDelta.Index < 0 { + return Message{}, errors.New("unsupported LiteLLM tool call index") + } + toolCall := toolCalls[toolCallDelta.Index] + if toolCallDelta.ID != "" { + toolCall.ID = toolCallDelta.ID + } + if toolCallDelta.Type != "" { + toolCall.Type = toolCallDelta.Type + } + if toolCallDelta.Function.Name != "" { + toolCall.Function.Name = toolCallDelta.Function.Name + } + toolCall.Function.Arguments += toolCallDelta.Function.Arguments + toolCalls[toolCallDelta.Index] = toolCall + if content.Len()+toolCallsSize(toolCalls) > liteLLMResponseMaxBytes { + return Message{}, errors.New("LiteLLM response exceeds size limit") + } + } + } + if !done { + return Message{}, errors.New("LiteLLM stream ended without [DONE]") + } + + id, err := newUUID() + if err != nil { + return Message{}, fmt.Errorf("generate assistant message ID: %w", err) + } + return Message{ + ID: id, + Role: RoleAssistant, + Content: content.String(), + ToolCalls: orderedToolCalls(toolCalls), + CreatedAt: time.Now().UTC(), + }, nil +} + +func toolCallsSize(toolCalls map[int]ToolCall) int { + total := 0 + for _, call := range toolCalls { + total += len(call.ID) + len(call.Type) + len(call.Function.Name) + len(call.Function.Arguments) + } + return total +} + +func liteLLMMessages(messages []Message) []liteLLMMessage { + mapped := make([]liteLLMMessage, 0, len(messages)) + for _, message := range messages { + switch message.Role { + case RoleUser, RoleAssistant, RoleTool: + mapped = append(mapped, liteLLMMessage{ + Role: message.Role, + Content: message.Content, + ToolCallID: message.ToolCallID, + ToolCalls: message.ToolCalls, + }) + } + } + return mapped +} + +func bashTool() liteLLMTool { + timeoutMinimum, timeoutMaximum := 250, 60000 + outputMinimum, outputMaximum := 256, 100000 + return liteLLMTool{ + Type: "function", + Function: liteLLMToolFunction{ + Name: "bash", + Description: bashToolDescription, + Parameters: liteLLMToolParameters{ + Type: "object", + AdditionalProperties: false, + Properties: map[string]liteLLMToolProperty{ + "command": {Type: "string"}, + "timeout_ms": {Type: "integer", Minimum: &timeoutMinimum, Maximum: &timeoutMaximum}, + "max_output_chars": {Type: "integer", Minimum: &outputMinimum, Maximum: &outputMaximum}, + }, + Required: []string{"command"}, + }, + }, + } +} + +func readSSEData(reader *bufio.Reader) (string, error) { + var dataLines []string + for { + line, err := reader.ReadString('\n') + if len(line) > 0 { + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + if line == "" { + if len(dataLines) > 0 { + return strings.Join(dataLines, "\n"), nil + } + } else if strings.HasPrefix(line, "data:") { + data := strings.TrimPrefix(line, "data:") + dataLines = append(dataLines, strings.TrimPrefix(data, " ")) + } + } + if err != nil { + if errors.Is(err, io.EOF) && len(dataLines) > 0 { + return strings.Join(dataLines, "\n"), nil + } + return "", err + } + } +} + +func orderedToolCalls(toolCalls map[int]ToolCall) []ToolCall { + if len(toolCalls) == 0 { + return nil + } + indexes := make([]int, 0, len(toolCalls)) + for index := range toolCalls { + indexes = append(indexes, index) + } + sort.Ints(indexes) + ordered := make([]ToolCall, 0, len(indexes)) + for _, index := range indexes { + ordered = append(ordered, toolCalls[index]) + } + return ordered +} + +func liteLLMResponseError(response *http.Response) error { + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return fmt.Errorf("read LiteLLM error response: %w", err) + } + + message := strings.TrimSpace(string(body)) + var payload struct { + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(body, &payload) == nil && len(payload.Error) > 0 { + var detail struct { + Message string `json:"message"` + } + if json.Unmarshal(payload.Error, &detail) == nil && detail.Message != "" { + message = detail.Message + } else { + var errorString string + if json.Unmarshal(payload.Error, &errorString) == nil && errorString != "" { + message = errorString + } + } + } + if message == "" { + message = response.Status + } + return fmt.Errorf("LiteLLM request failed: status %d: %s", response.StatusCode, message) +} diff --git a/libs/fleet/backend/chat/litellm_test.go b/libs/fleet/backend/chat/litellm_test.go new file mode 100644 index 0000000000..0a074d87a8 --- /dev/null +++ b/libs/fleet/backend/chat/litellm_test.go @@ -0,0 +1,265 @@ +package chat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestLiteLLMCompleteStreamsContentAndRequest(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + t.Errorf("method = %s, want POST", request.Method) + } + if request.URL.Path != "/v1/chat/completions" { + t.Errorf("path = %s, want /v1/chat/completions", request.URL.Path) + } + if authorization := request.Header.Get("Authorization"); authorization != "Bearer test-key" { + t.Errorf("Authorization = %q, want bearer token", authorization) + } + + var body struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters struct { + Type string `json:"type"` + AdditionalProperties bool `json:"additionalProperties"` + Properties map[string]struct { + Type string `json:"type"` + Minimum *int `json:"minimum"` + Maximum *int `json:"maximum"` + } `json:"properties"` + Required []string `json:"required"` + } `json:"parameters"` + } `json:"function"` + } `json:"tools"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Model != "test-model" || !body.Stream { + t.Errorf("model/stream = %q/%t, want test-model/true", body.Model, body.Stream) + } + for _, message := range body.Messages { + if message.Role == "system" { + t.Fatal("request included a system-role message") + } + } + if len(body.Tools) != 1 { + t.Fatalf("tool count = %d, want 1", len(body.Tools)) + } + tool := body.Tools[0] + if tool.Type != "function" || tool.Function.Name != "bash" { + t.Fatalf("tool = %#v, want bash function", tool) + } + if tool.Function.Description != "Execute a command in a temporary, isolated in-browser virtual filesystem. Network and host filesystem access are unavailable." { + t.Errorf("description = %q", tool.Function.Description) + } + parameters := tool.Function.Parameters + if parameters.Type != "object" || parameters.AdditionalProperties { + t.Errorf("parameters object/additionalProperties = %q/%t", parameters.Type, parameters.AdditionalProperties) + } + if got := parameters.Required; len(got) != 1 || got[0] != "command" { + t.Errorf("required = %#v, want [command]", got) + } + if parameters.Properties["command"].Type != "string" || parameters.Properties["timeout_ms"].Minimum == nil || *parameters.Properties["timeout_ms"].Minimum != 250 || parameters.Properties["timeout_ms"].Maximum == nil || *parameters.Properties["timeout_ms"].Maximum != 60000 || parameters.Properties["max_output_chars"].Minimum == nil || *parameters.Properties["max_output_chars"].Minimum != 256 || parameters.Properties["max_output_chars"].Maximum == nil || *parameters.Properties["max_output_chars"].Maximum != 100000 { + t.Errorf("unexpected bash tool parameters: %#v", parameters) + } + + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"}}]}\n\n") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"content\":\"world\"}}]}\n\n") + fmt.Fprint(writer, "data: [DONE]\n\n") + })) + defer server.Close() + + client := NewLiteLLMClient(server.URL+"/v1", "test-key", "test-model") + var deltas []string + message, err := client.Complete(context.Background(), []Message{ + {Role: Role("system"), Content: "must not be sent"}, + {Role: RoleUser, Content: "Say hello"}, + }, func(delta string) error { + deltas = append(deltas, delta) + return nil + }) + if err != nil { + t.Fatal(err) + } + if message.Role != RoleAssistant || message.Content != "Hello world" { + t.Fatalf("message = %#v, want assembled assistant content", message) + } + if message.ID == "" || message.CreatedAt.IsZero() { + t.Fatalf("message was not stamped: %#v", message) + } + if got := strings.Join(deltas, ""); got != "Hello world" { + t.Fatalf("deltas = %#v, want Hello world", deltas) + } +} + +func TestLiteLLMCompleteAssemblesIndexedToolCallsAcrossLargeFrames(t *testing.T) { + largeArguments := strings.Repeat("x", 70_000) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"p\"}}]}}]}\n\n") + fmt.Fprintf(writer, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":%q}}]}}]}\n\n", largeArguments+`wd"}`) + fmt.Fprint(writer, "data: [DONE]\n\n") + })) + defer server.Close() + + message, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(context.Background(), []Message{{Role: RoleUser, Content: "where am I?"}}, nil) + if err != nil { + t.Fatal(err) + } + if len(message.ToolCalls) != 1 { + t.Fatalf("tool calls = %#v, want one", message.ToolCalls) + } + wantArguments := `{"command":"p` + largeArguments + `wd"}` + want := ToolCall{ID: "call-1", Type: "function", Function: ToolFunction{Name: "bash", Arguments: wantArguments}} + if got := message.ToolCalls[0]; got != want { + t.Fatalf("tool call = %#v, want %#v", got, want) + } +} + +func TestLiteLLMCompleteReturnsNonSuccessJSONError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(writer, `{"error":{"message":"rate limited"}}`) + })) + defer server.Close() + + _, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(context.Background(), nil, nil) + if err == nil || !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("error = %v, want status and JSON message", err) + } +} + +func TestLiteLLMCompleteRejectsMalformedSSE(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {not json}\n\n") + })) + defer server.Close() + + _, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(context.Background(), nil, nil) + if err == nil || !strings.Contains(err.Error(), "parse SSE chunk") { + t.Fatalf("error = %v, want malformed SSE error", err) + } +} + +func TestLiteLLMCompleteRejectsMissingDone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"content\":\"incomplete\"}}]}\n\n") + })) + defer server.Close() + + _, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(context.Background(), nil, nil) + if err == nil || !strings.Contains(err.Error(), "[DONE]") { + t.Fatalf("error = %v, want missing [DONE] error", err) + } +} + +func TestLiteLLMCompleteReturnsCallbackError(t *testing.T) { + callbackError := errors.New("client disconnected") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") + fmt.Fprint(writer, "data: [DONE]\n\n") + })) + defer server.Close() + + _, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(context.Background(), nil, func(string) error { + return callbackError + }) + if !errors.Is(err, callbackError) { + t.Fatalf("error = %v, want callback error", err) + } +} + +func TestLiteLLMCompleteReturnsContextCancellation(t *testing.T) { + requestStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + writer.WriteHeader(http.StatusOK) + writer.(http.Flusher).Flush() + close(requestStarted) + <-request.Context().Done() + })) + defer server.Close() + + requestContext, cancel := context.WithCancel(context.Background()) + defer cancel() + errorsChannel := make(chan error, 1) + go func() { + _, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(requestContext, nil, nil) + errorsChannel <- err + }() + + select { + case <-requestStarted: + cancel() + case <-time.After(time.Second): + t.Fatal("request did not reach test server") + } + select { + case err := <-errorsChannel: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context cancellation", err) + } + case <-time.After(time.Second): + t.Fatal("Complete did not return after context cancellation") + } +} + +func TestLiteLLMCompleteReturnsCancellationFromSuccessfulDeltaCallback(t *testing.T) { + requestContext, cancel := context.WithCancel(context.Background()) + defer cancel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(writer, "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n") + fmt.Fprint(writer, "data: [DONE]\n\n") + })) + defer server.Close() + + message, err := NewLiteLLMClient(server.URL, "", "test-model").Complete(requestContext, nil, func(string) error { + cancel() + return nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context cancellation", err) + } + if message.ID != "" || message.Content != "" || message.Role != "" { + t.Fatalf("message = %#v, want zero message after cancellation", message) + } +} + +func TestLiteLLMCompleteRejectsOversizedAssistantOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(writer, "data: {\"choices\":[{\"delta\":{\"content\":%q}}]}\n\n", strings.Repeat("x", (128<<10)+1)) + fmt.Fprint(writer, "data: [DONE]\n\n") + })) + defer server.Close() + + client := NewLiteLLMClient(server.URL, "", "test-model") + _, err := client.Complete(context.Background(), []Message{{Role: RoleUser, Content: "hello"}}, nil) + if err == nil || !strings.Contains(err.Error(), "response exceeds") { + t.Fatalf("error = %v, want response size error", err) + } +} diff --git a/libs/fleet/backend/chat/store.go b/libs/fleet/backend/chat/store.go new file mode 100644 index 0000000000..46b3baa0c4 --- /dev/null +++ b/libs/fleet/backend/chat/store.go @@ -0,0 +1,207 @@ +package chat + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrConversationNotFound = errors.New("conversation not found") + ErrConversationLimit = errors.New("conversation storage limit reached") +) + +const ( + memoryConversationMaxPerOwner = 100 + memoryConversationMaxBytes = 64 << 20 +) + +type ConversationStore interface { + Create(ctx context.Context, ownerID string) (*Conversation, error) + List(ctx context.Context, ownerID string) ([]ConversationSummary, error) + Get(ctx context.Context, ownerID, conversationID string) (*Conversation, error) + Append(ctx context.Context, ownerID, conversationID string, messages ...Message) error +} + +type conversationRecord struct { + OwnerID string + Conversation +} + +type MemoryConversationStore struct { + mu sync.RWMutex + conversations map[string]*conversationRecord + ownerConversations map[string]int + maxPerOwner int + maxBytes int + totalBytes int +} + +func NewMemoryConversationStore() *MemoryConversationStore { + return newMemoryConversationStore(memoryConversationMaxPerOwner, memoryConversationMaxBytes) +} + +func newMemoryConversationStore(maxPerOwner, maxBytes int) *MemoryConversationStore { + return &MemoryConversationStore{ + conversations: make(map[string]*conversationRecord), + ownerConversations: make(map[string]int), + maxPerOwner: maxPerOwner, + maxBytes: maxBytes, + } +} + +func (store *MemoryConversationStore) Create(_ context.Context, ownerID string) (*Conversation, error) { + store.mu.Lock() + defer store.mu.Unlock() + + if store.ownerConversations[ownerID] >= store.maxPerOwner { + return nil, ErrConversationLimit + } + id, err := newUUID() + if err != nil { + return nil, fmt.Errorf("generate conversation ID: %w", err) + } + now := time.Now().UTC() + record := &conversationRecord{ + OwnerID: ownerID, + Conversation: Conversation{ + ID: id, + CreatedAt: now, + UpdatedAt: now, + }, + } + store.conversations[id] = record + store.ownerConversations[ownerID]++ + + return cloneConversation(record.Conversation), nil +} + +func (store *MemoryConversationStore) List(_ context.Context, ownerID string) ([]ConversationSummary, error) { + store.mu.RLock() + summaries := make([]ConversationSummary, 0) + for _, record := range store.conversations { + if record.OwnerID != ownerID { + continue + } + summaries = append(summaries, ConversationSummary{ + ID: record.ID, + Title: record.Title, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }) + } + store.mu.RUnlock() + + sort.Slice(summaries, func(left, right int) bool { + return summaries[left].UpdatedAt.After(summaries[right].UpdatedAt) + }) + return summaries, nil +} + +func (store *MemoryConversationStore) Get(_ context.Context, ownerID, conversationID string) (*Conversation, error) { + store.mu.RLock() + record, ok := store.conversations[conversationID] + if !ok || record.OwnerID != ownerID { + store.mu.RUnlock() + return nil, ErrConversationNotFound + } + conversation := cloneConversation(record.Conversation) + store.mu.RUnlock() + + return conversation, nil +} + +func (store *MemoryConversationStore) Append(_ context.Context, ownerID, conversationID string, messages ...Message) error { + store.mu.Lock() + defer store.mu.Unlock() + + record, ok := store.conversations[conversationID] + if !ok || record.OwnerID != ownerID { + return ErrConversationNotFound + } + if len(messages) == 0 { + return nil + } + + now := time.Now().UTC() + pending := make([]Message, len(messages)) + addedBytes := 0 + for index, message := range messages { + if message.ID == "" { + id, err := newUUID() + if err != nil { + return fmt.Errorf("generate message ID: %w", err) + } + message.ID = id + } + if message.CreatedAt.IsZero() { + message.CreatedAt = now + } + pending[index] = cloneMessage(message) + addedBytes += messageSize(message) + } + if store.totalBytes+addedBytes > store.maxBytes { + return ErrConversationLimit + } + record.Messages = append(record.Messages, pending...) + store.totalBytes += addedBytes + if record.Title == "" { + record.Title = titleFromFirstUserMessage(record.Messages) + } + record.UpdatedAt = now + + return nil +} + +func messageSize(message Message) int { + total := len(message.ID) + len(message.Role) + len(message.Content) + len(message.ToolCallID) + for _, call := range message.ToolCalls { + total += len(call.ID) + len(call.Type) + len(call.Function.Name) + len(call.Function.Arguments) + } + return total +} + +func newUUID() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + bytes[6] = bytes[6]&0x0f | 0x40 + bytes[8] = bytes[8]&0x3f | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]), nil +} + +func titleFromFirstUserMessage(messages []Message) string { + for _, message := range messages { + if message.Role != RoleUser { + continue + } + title := strings.TrimSpace(message.Content) + runes := []rune(title) + if len(runes) > 80 { + return string(runes[:80]) + } + return title + } + return "" +} + +func cloneConversation(conversation Conversation) *Conversation { + cloned := conversation + cloned.Messages = make([]Message, len(conversation.Messages)) + for index, message := range conversation.Messages { + cloned.Messages[index] = cloneMessage(message) + } + return &cloned +} + +func cloneMessage(message Message) Message { + cloned := message + cloned.ToolCalls = append([]ToolCall(nil), message.ToolCalls...) + return cloned +} diff --git a/libs/fleet/backend/chat/store_test.go b/libs/fleet/backend/chat/store_test.go new file mode 100644 index 0000000000..9f0b0933ef --- /dev/null +++ b/libs/fleet/backend/chat/store_test.go @@ -0,0 +1,237 @@ +package chat + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" +) + +func TestMemoryStoreTitlesAndOrdersConversations(t *testing.T) { + store := NewMemoryConversationStore() + first, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + second, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + if err := store.Append(context.Background(), "owner-a", first.ID, Message{Role: RoleUser, Content: " First useful question "}); err != nil { + t.Fatal(err) + } + if err := store.Append(context.Background(), "owner-a", second.ID, Message{Role: RoleUser, Content: "Newest question"}); err != nil { + t.Fatal(err) + } + + got, err := store.List(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].ID != second.ID || got[1].Title != "First useful question" { + t.Fatalf("unexpected summaries: %#v", got) + } +} + +func TestMemoryStoreHidesOtherOwners(t *testing.T) { + store := NewMemoryConversationStore() + conversation, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + if _, err := store.Get(context.Background(), "owner-b", conversation.ID); !errors.Is(err, ErrConversationNotFound) { + t.Fatalf("Get error = %v, want ErrConversationNotFound", err) + } + if err := store.Append(context.Background(), "owner-b", conversation.ID, Message{Role: RoleUser, Content: "no"}); !errors.Is(err, ErrConversationNotFound) { + t.Fatalf("Append error = %v, want ErrConversationNotFound", err) + } +} + +func TestMemoryStoreReturnsDeepCopies(t *testing.T) { + store := NewMemoryConversationStore() + conversation, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + message := Message{ + Role: RoleAssistant, + Content: "original", + ToolCalls: []ToolCall{{ + ID: "call-1", + Type: "function", + Function: ToolFunction{ + Name: "run_bash", + Arguments: `{"command":"pwd"}`, + }, + }}, + } + if err := store.Append(context.Background(), "owner-a", conversation.ID, message); err != nil { + t.Fatal(err) + } + + got, err := store.Get(context.Background(), "owner-a", conversation.ID) + if err != nil { + t.Fatal(err) + } + got.Title = "mutated" + got.Messages[0].Content = "mutated" + got.Messages[0].ToolCalls[0].Function.Name = "mutated" + + again, err := store.Get(context.Background(), "owner-a", conversation.ID) + if err != nil { + t.Fatal(err) + } + if again.Title != "" || again.Messages[0].Content != "original" || again.Messages[0].ToolCalls[0].Function.Name != "run_bash" { + t.Fatalf("store leaked returned mutation: %#v", again) + } +} + +func TestMemoryStoreConcurrentAppend(t *testing.T) { + store := NewMemoryConversationStore() + conversation, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + + const writers = 25 + var waitGroup sync.WaitGroup + waitGroup.Add(writers) + for index := range writers { + go func() { + defer waitGroup.Done() + if err := store.Append(context.Background(), "owner-a", conversation.ID, Message{Role: RoleUser, Content: fmt.Sprintf("message-%d", index)}); err != nil { + t.Errorf("Append() error = %v", err) + } + }() + } + waitGroup.Wait() + + got, err := store.Get(context.Background(), "owner-a", conversation.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Messages) != writers { + t.Fatalf("message count = %d, want %d", len(got.Messages), writers) + } +} + +func TestMemoryStoreCreateDoesNotRaceWithDiscoveryAndAppend(t *testing.T) { + const ( + creators = 4 + conversationsPerCreator = 1_000 + ) + + store := newMemoryConversationStore(creators*conversationsPerCreator, memoryConversationMaxBytes) + ctx := context.Background() + done := make(chan struct{}) + var discoveryWaitGroup sync.WaitGroup + discoveryWaitGroup.Add(1) + go func() { + defer discoveryWaitGroup.Done() + seen := make(map[string]struct{}) + for { + select { + case <-done: + return + default: + } + + summaries, err := store.List(ctx, "owner-a") + if err != nil { + t.Errorf("List() error = %v", err) + return + } + for _, summary := range summaries { + if _, ok := seen[summary.ID]; ok { + continue + } + seen[summary.ID] = struct{}{} + if err := store.Append(ctx, "owner-a", summary.ID, Message{Role: RoleUser, Content: "discovered"}); err != nil { + t.Errorf("Append() error = %v", err) + return + } + } + } + }() + + var createWaitGroup sync.WaitGroup + createWaitGroup.Add(creators) + for range creators { + go func() { + defer createWaitGroup.Done() + for range conversationsPerCreator { + if _, err := store.Create(ctx, "owner-a"); err != nil { + t.Errorf("Create() error = %v", err) + return + } + } + }() + } + createWaitGroup.Wait() + close(done) + discoveryWaitGroup.Wait() +} + +func TestMemoryStoreStampsMessagesAndCapsTitle(t *testing.T) { + store := NewMemoryConversationStore() + conversation, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + content := " " + strings.Repeat("a", 81) + " " + if err := store.Append(context.Background(), "owner-a", conversation.ID, Message{Role: RoleUser, Content: content}); err != nil { + t.Fatal(err) + } + + got, err := store.Get(context.Background(), "owner-a", conversation.ID) + if err != nil { + t.Fatal(err) + } + if got.Title != content[2:82] { + t.Fatalf("title = %q, want 80-character prefix", got.Title) + } + if got.Messages[0].ID == "" { + t.Fatal("message ID was not stamped") + } + if got.Messages[0].CreatedAt.IsZero() { + t.Fatal("message CreatedAt was not stamped") + } + if !got.UpdatedAt.After(got.CreatedAt) && !got.UpdatedAt.Equal(got.CreatedAt) { + t.Fatalf("UpdatedAt = %v, CreatedAt = %v", got.UpdatedAt, got.CreatedAt) + } +} + +func TestMemoryConversationStoreLimitsConversationsPerOwner(t *testing.T) { + store := newMemoryConversationStore(2, 1<<20) + for index := 0; index < 2; index++ { + if _, err := store.Create(context.Background(), "owner-a"); err != nil { + t.Fatalf("create %d: %v", index, err) + } + } + if _, err := store.Create(context.Background(), "owner-a"); !errors.Is(err, ErrConversationLimit) { + t.Fatalf("error = %v, want ErrConversationLimit", err) + } + if _, err := store.Create(context.Background(), "owner-b"); err != nil { + t.Fatalf("other owner create: %v", err) + } +} + +func TestMemoryConversationStoreLimitsAggregateBytes(t *testing.T) { + store := newMemoryConversationStore(10, 1024) + conversation, err := store.Create(context.Background(), "owner-a") + if err != nil { + t.Fatal(err) + } + if err := store.Append(context.Background(), "owner-a", conversation.ID, Message{Role: RoleUser, Content: strings.Repeat("x", 2048)}); !errors.Is(err, ErrConversationLimit) { + t.Fatalf("error = %v, want ErrConversationLimit", err) + } + stored, err := store.Get(context.Background(), "owner-a", conversation.ID) + if err != nil { + t.Fatal(err) + } + if len(stored.Messages) != 0 { + t.Fatalf("oversized append mutated conversation: %#v", stored.Messages) + } +} diff --git a/libs/fleet/backend/chat/types.go b/libs/fleet/backend/chat/types.go new file mode 100644 index 0000000000..d28c32c367 --- /dev/null +++ b/libs/fleet/backend/chat/types.go @@ -0,0 +1,73 @@ +package chat + +import ( + "encoding/json" + "time" +) + +type Role string + +const ( + RoleUser Role = "user" + RoleAssistant Role = "assistant" + RoleTool Role = "tool" +) + +type ToolFunction struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolFunction `json:"function"` +} + +type Message struct { + ID string `json:"id,omitempty"` + Role Role `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +type Conversation struct { + ID string `json:"id"` + Title string `json:"title"` + Messages []Message `json:"messages"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type messageJSON struct { + ID string `json:"id,omitempty"` + Role Role `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +func (message Message) MarshalJSON() ([]byte, error) { + var createdAt *time.Time + if !message.CreatedAt.IsZero() { + createdAt = &message.CreatedAt + } + return json.Marshal(messageJSON{ + ID: message.ID, + Role: message.Role, + Content: message.Content, + ToolCallID: message.ToolCallID, + ToolCalls: message.ToolCalls, + CreatedAt: createdAt, + }) +} diff --git a/libs/fleet/backend/config/config.go b/libs/fleet/backend/config/config.go index 7e93ab1811..a1b4d325b8 100644 --- a/libs/fleet/backend/config/config.go +++ b/libs/fleet/backend/config/config.go @@ -27,6 +27,7 @@ type Configuration struct { Gateway GatewayConfiguration Database DatabaseConfiguration Stripe StripeConfiguration + Chat ChatConfiguration Metrics MetricsConfiguration Telemetry TelemetryConfiguration } @@ -97,6 +98,13 @@ type DatabaseConfiguration struct { StateQueryTenantPassword string // STATE_QUERY_TENANT_PASSWORD — shared tenant query login password } +type ChatConfiguration struct { + Enabled bool + BaseURL string + APIKey string + Model string +} + type MetricsConfiguration struct { Addr string // METRICS_ADDR — Prometheus listen addr } @@ -146,6 +154,10 @@ var specs = []flagSpec{ {"stripe.checkout-success-url", "stripe-checkout-success-url", "STRIPE_CHECKOUT_SUCCESS_URL", "", "Stripe Checkout success redirect URL"}, {"stripe.checkout-cancel-url", "stripe-checkout-cancel-url", "STRIPE_CHECKOUT_CANCEL_URL", "", "Stripe Checkout cancel redirect URL"}, {"stripe.portal-return-url", "stripe-portal-return-url", "STRIPE_PORTAL_RETURN_URL", "", "Stripe Billing Portal return URL"}, + {"chat.enabled", "chat-enabled", "CYCLOPS_CS_CHAT_ENABLED", "false", "enable browser bash chat"}, + {"chat.base-url", "litellm-base-url", "LITELLM_BASE_URL", "", "LiteLLM OpenAI-compatible base URL"}, + {"chat.api-key", "litellm-api-key", "LITELLM_API_KEY", "", "LiteLLM virtual key"}, + {"chat.model", "litellm-model", "LITELLM_MODEL", "large", "LiteLLM model alias"}, {"metrics.addr", "metrics-addr", "METRICS_ADDR", ":9091", "Prometheus metrics listen address"}, {"telemetry.endpoint", "otel-endpoint", "OTEL_EXPORTER_OTLP_ENDPOINT", "https://otel.cua.ai", "OTLP HTTP traces endpoint"}, {"telemetry.protocol", "otel-protocol", "OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf", "OTLP exporter protocol"}, @@ -238,6 +250,12 @@ func LoadConfig() (*Configuration, error) { CheckoutCancelURL: viper.GetString("stripe.checkout-cancel-url"), PortalReturnURL: viper.GetString("stripe.portal-return-url"), }, + Chat: ChatConfiguration{ + Enabled: viper.GetBool("chat.enabled"), + BaseURL: viper.GetString("chat.base-url"), + APIKey: viper.GetString("chat.api-key"), + Model: viper.GetString("chat.model"), + }, Metrics: MetricsConfiguration{Addr: viper.GetString("metrics.addr")}, Telemetry: TelemetryConfiguration{ Endpoint: viper.GetString("telemetry.endpoint"), @@ -251,5 +269,8 @@ func LoadConfig() (*Configuration, error) { if cfg.Keycloak.AdminClientSecret == "" { return nil, fmt.Errorf("KC_ADMIN_CLIENT_SECRET is required") } + if cfg.Chat.Enabled && (cfg.Chat.BaseURL == "" || cfg.Chat.APIKey == "") { + return nil, fmt.Errorf("chat.enabled requires LITELLM_BASE_URL and LITELLM_API_KEY") + } return cfg, nil } diff --git a/libs/fleet/backend/config/config_test.go b/libs/fleet/backend/config/config_test.go index 100401476e..bc833bc909 100644 --- a/libs/fleet/backend/config/config_test.go +++ b/libs/fleet/backend/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "slices" + "strings" "testing" "github.com/spf13/pflag" @@ -181,3 +182,54 @@ func TestLoadConfig_StateDatabaseURLs(t *testing.T) { t.Fatalf("Database.StateQueryTenantPassword = %q, want %q", got, want) } } + + +func loadChatTestConfig(t *testing.T) (*Configuration, error) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) + t.Setenv("KC_ADMIN_CLIENT_SECRET", "secret") + RegisterFlags(pflag.NewFlagSet("chat-test", pflag.ContinueOnError)) + return LoadConfig() +} + +func TestLoadConfig_ChatDisabledDefaults(t *testing.T) { + cfg, err := loadChatTestConfig(t) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if cfg.Chat.Enabled || cfg.Chat.BaseURL != "" || cfg.Chat.APIKey != "" || cfg.Chat.Model != "large" { + t.Fatalf("Chat = %#v, want disabled defaults", cfg.Chat) + } +} + +func TestLoadConfig_ChatEnabledWithCredentials(t *testing.T) { + t.Setenv("CYCLOPS_CS_CHAT_ENABLED", "true") + t.Setenv("LITELLM_BASE_URL", "https://litellm.example/v1") + t.Setenv("LITELLM_API_KEY", "secret") + t.Setenv("LITELLM_MODEL", "browser-bash") + cfg, err := loadChatTestConfig(t) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Chat.Enabled || cfg.Chat.BaseURL != "https://litellm.example/v1" || cfg.Chat.APIKey != "secret" || cfg.Chat.Model != "browser-bash" { + t.Fatalf("Chat = %#v, want enabled configuration", cfg.Chat) + } +} + +func TestLoadConfig_ChatEnabledRequiresCredentials(t *testing.T) { + for name, env := range map[string]map[string]string{ + "missing base URL": {"CYCLOPS_CS_CHAT_ENABLED": "true", "LITELLM_API_KEY": "secret"}, + "missing API key": {"CYCLOPS_CS_CHAT_ENABLED": "true", "LITELLM_BASE_URL": "https://litellm.example/v1"}, + } { + t.Run(name, func(t *testing.T) { + for key, value := range env { + t.Setenv(key, value) + } + _, err := loadChatTestConfig(t) + if err == nil || !strings.Contains(err.Error(), "chat") { + t.Fatalf("LoadConfig() error = %v, want chat credential error", err) + } + }) + } +} diff --git a/libs/fleet/backend/docs/docs.go b/libs/fleet/backend/docs/docs.go index 0775bec6b2..233d8aea58 100644 --- a/libs/fleet/backend/docs/docs.go +++ b/libs/fleet/backend/docs/docs.go @@ -251,6 +251,217 @@ const docTemplate = `{ } } }, + "/api/chat/conversations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "List the calling user's chat conversations", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.ConversationSummary" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "Create a chat conversation", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/chat.Conversation" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/chat/conversations/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "Get a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/chat.Conversation" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/chat/conversations/{id}/turns": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/x-ndjson" + ], + "tags": [ + "chat" + ], + "summary": "Send a chat turn and stream the assistant response", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User message or tool results", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.TurnRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.turnEvent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/api/config": { "get": { "security": [ @@ -995,6 +1206,110 @@ const docTemplate = `{ } } }, + "chat.Conversation": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.Message" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "chat.ConversationSummary": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "chat.Message": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/chat.Role" + }, + "tool_call_id": { + "type": "string" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.ToolCall" + } + } + } + }, + "chat.Role": { + "type": "string", + "enum": [ + "user", + "assistant", + "tool" + ], + "x-enum-varnames": [ + "RoleUser", + "RoleAssistant", + "RoleTool" + ] + }, + "chat.ToolCall": { + "type": "object", + "properties": { + "function": { + "$ref": "#/definitions/chat.ToolFunction" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "chat.ToolFunction": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "handlers.BillingSessionResponse": { "type": "object", "properties": { @@ -1012,6 +1327,9 @@ const docTemplate = `{ }, "billing": { "type": "boolean" + }, + "chat": { + "type": "boolean" } } }, @@ -1157,6 +1475,17 @@ const docTemplate = `{ } } }, + "handlers.TurnRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.Message" + } + } + } + }, "handlers.UserKeyResponse": { "type": "object", "properties": { @@ -1177,6 +1506,20 @@ const docTemplate = `{ } } }, + "handlers.turnEvent": { + "type": "object", + "properties": { + "delta": { + "type": "string" + }, + "message": { + "$ref": "#/definitions/chat.Message" + }, + "type": { + "type": "string" + } + } + }, "keycloak.KeyClient": { "type": "object", "properties": { diff --git a/libs/fleet/backend/docs/swagger.json b/libs/fleet/backend/docs/swagger.json index 1c62e97fb6..2a10998170 100644 --- a/libs/fleet/backend/docs/swagger.json +++ b/libs/fleet/backend/docs/swagger.json @@ -244,6 +244,217 @@ } } }, + "/api/chat/conversations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "List the calling user's chat conversations", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.ConversationSummary" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "Create a chat conversation", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/chat.Conversation" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/chat/conversations/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "chat" + ], + "summary": "Get a chat conversation", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/chat.Conversation" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, + "/api/chat/conversations/{id}/turns": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/x-ndjson" + ], + "tags": [ + "chat" + ], + "summary": "Send a chat turn and stream the assistant response", + "parameters": [ + { + "type": "string", + "description": "Conversation ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User message or tool results", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.TurnRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.turnEvent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/handlers.ErrorResponse" + } + } + } + } + }, "/api/config": { "get": { "security": [ @@ -988,6 +1199,110 @@ } } }, + "chat.Conversation": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.Message" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "chat.ConversationSummary": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "chat.Message": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/chat.Role" + }, + "tool_call_id": { + "type": "string" + }, + "tool_calls": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.ToolCall" + } + } + } + }, + "chat.Role": { + "type": "string", + "enum": [ + "user", + "assistant", + "tool" + ], + "x-enum-varnames": [ + "RoleUser", + "RoleAssistant", + "RoleTool" + ] + }, + "chat.ToolCall": { + "type": "object", + "properties": { + "function": { + "$ref": "#/definitions/chat.ToolFunction" + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "chat.ToolFunction": { + "type": "object", + "properties": { + "arguments": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, "handlers.BillingSessionResponse": { "type": "object", "properties": { @@ -1005,6 +1320,9 @@ }, "billing": { "type": "boolean" + }, + "chat": { + "type": "boolean" } } }, @@ -1150,6 +1468,17 @@ } } }, + "handlers.TurnRequest": { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/chat.Message" + } + } + } + }, "handlers.UserKeyResponse": { "type": "object", "properties": { @@ -1170,6 +1499,20 @@ } } }, + "handlers.turnEvent": { + "type": "object", + "properties": { + "delta": { + "type": "string" + }, + "message": { + "$ref": "#/definitions/chat.Message" + }, + "type": { + "type": "string" + } + } + }, "keycloak.KeyClient": { "type": "object", "properties": { diff --git a/libs/fleet/backend/docs/swagger.yaml b/libs/fleet/backend/docs/swagger.yaml index 5b73fc72cf..b9420f6e5d 100644 --- a/libs/fleet/backend/docs/swagger.yaml +++ b/libs/fleet/backend/docs/swagger.yaml @@ -23,6 +23,75 @@ definitions: - card - payment_method_present type: object + chat.Conversation: + properties: + created_at: + type: string + id: + type: string + messages: + items: + $ref: '#/definitions/chat.Message' + type: array + title: + type: string + updated_at: + type: string + type: object + chat.ConversationSummary: + properties: + created_at: + type: string + id: + type: string + title: + type: string + updated_at: + type: string + type: object + chat.Message: + properties: + content: + type: string + created_at: + type: string + id: + type: string + role: + $ref: '#/definitions/chat.Role' + tool_call_id: + type: string + tool_calls: + items: + $ref: '#/definitions/chat.ToolCall' + type: array + type: object + chat.Role: + enum: + - user + - assistant + - tool + type: string + x-enum-varnames: + - RoleUser + - RoleAssistant + - RoleTool + chat.ToolCall: + properties: + function: + $ref: '#/definitions/chat.ToolFunction' + id: + type: string + type: + type: string + type: object + chat.ToolFunction: + properties: + arguments: + type: string + name: + type: string + type: object handlers.BillingSessionResponse: properties: url: @@ -39,6 +108,8 @@ definitions: type: boolean billing: type: boolean + chat: + type: boolean type: object handlers.CreateKeyRequest: properties: @@ -133,6 +204,13 @@ definitions: status: type: string type: object + handlers.TurnRequest: + properties: + messages: + items: + $ref: '#/definitions/chat.Message' + type: array + type: object handlers.UserKeyResponse: properties: client_id: @@ -146,6 +224,15 @@ definitions: type: string type: array type: object + handlers.turnEvent: + properties: + delta: + type: string + message: + $ref: '#/definitions/chat.Message' + type: + type: string + type: object keycloak.KeyClient: properties: client_id: @@ -323,6 +410,139 @@ paths: summary: Receive Stripe webhook tags: - billing + /api/chat/conversations: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/chat.ConversationSummary' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List the calling user's chat conversations + tags: + - chat + post: + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/chat.Conversation' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "413": + description: Request Entity Too Large + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a chat conversation + tags: + - chat + /api/chat/conversations/{id}: + get: + parameters: + - description: Conversation ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/chat.Conversation' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get a chat conversation + tags: + - chat + /api/chat/conversations/{id}/turns: + post: + consumes: + - application/json + parameters: + - description: Conversation ID + in: path + name: id + required: true + type: string + - description: User message or tool results + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.TurnRequest' + produces: + - application/x-ndjson + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.turnEvent' + "400": + description: Bad Request + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/handlers.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Send a chat turn and stream the assistant response + tags: + - chat /api/config: get: description: Returns OPA-evaluated feature flags for the authenticated SPA user. diff --git a/libs/fleet/backend/handlers/chat.go b/libs/fleet/backend/handlers/chat.go new file mode 100644 index 0000000000..ca77a4606a --- /dev/null +++ b/libs/fleet/backend/handlers/chat.go @@ -0,0 +1,446 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "cyclops-cs-backend/auth" + "cyclops-cs-backend/chat" +) + +const ( + chatTurnBodyMaxBytes = 256 << 10 + chatMessageMaxBytes = 128 << 10 + chatHistoryMaxBytes = 1 << 20 + chatHistoryMaxCount = 256 +) + +var errChatTurnTooLarge = errors.New("chat turn is too large") + +type TurnRequest struct { + Messages []chat.Message `json:"messages"` +} + +type turnEvent struct { + Type string `json:"type"` + Delta string `json:"delta,omitempty"` + Message *chat.Message `json:"message,omitempty"` +} + +func (h Handlers) chatUser(w http.ResponseWriter, r *http.Request) *auth.User { + if !h.ChatEnabled { + writeErr(w, http.StatusNotFound, "chat is disabled") + return nil + } + user := currentUser(r) + if user == nil || user.ID == "" { + writeErr(w, http.StatusUnauthorized, "missing user") + return nil + } + if h.Conversations == nil { + writeErr(w, http.StatusServiceUnavailable, "chat is not configured") + return nil + } + return user +} + +// CreateConversation godoc +// +// @Summary Create a chat conversation +// @Tags chat +// @Produce json +// @Success 201 {object} chat.Conversation +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 413 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/chat/conversations [post] +func (h Handlers) CreateConversation(w http.ResponseWriter, r *http.Request) { + user := h.chatUser(w, r) + if user == nil { + return + } + conversation, err := h.Conversations.Create(r.Context(), user.ID) + if errors.Is(err, chat.ErrConversationLimit) { + writeErr(w, http.StatusTooManyRequests, "conversation limit reached") + return + } + if err != nil { + slog.ErrorContext(r.Context(), "create chat conversation", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to create conversation") + return + } + writeJSON(w, http.StatusCreated, conversation) +} + +// ListConversations godoc +// +// @Summary List the calling user's chat conversations +// @Tags chat +// @Produce json +// @Success 200 {array} chat.ConversationSummary +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/chat/conversations [get] +func (h Handlers) ListConversations(w http.ResponseWriter, r *http.Request) { + user := h.chatUser(w, r) + if user == nil { + return + } + conversations, err := h.Conversations.List(r.Context(), user.ID) + if err != nil { + slog.ErrorContext(r.Context(), "list chat conversations", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to list conversations") + return + } + writeJSON(w, http.StatusOK, conversations) +} + +// GetConversation godoc +// +// @Summary Get a chat conversation +// @Tags chat +// @Produce json +// @Param id path string true "Conversation ID" +// @Success 200 {object} chat.Conversation +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/chat/conversations/{id} [get] +func (h Handlers) GetConversation(w http.ResponseWriter, r *http.Request) { + user := h.chatUser(w, r) + if user == nil { + return + } + conversation, err := h.Conversations.Get(r.Context(), user.ID, chatConversationID(r)) + if errors.Is(err, chat.ErrConversationNotFound) { + writeErr(w, http.StatusNotFound, "conversation not found") + return + } + if err != nil { + slog.ErrorContext(r.Context(), "get chat conversation", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to get conversation") + return + } + writeJSON(w, http.StatusOK, conversation) +} + +// CreateTurn godoc +// +// @Summary Send a chat turn and stream the assistant response +// @Tags chat +// @Accept json +// @Produce application/x-ndjson +// @Param id path string true "Conversation ID" +// @Param body body TurnRequest true "User message or tool results" +// @Success 200 {object} turnEvent +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Security BearerAuth +// @Router /api/chat/conversations/{id}/turns [post] +func (h Handlers) CreateTurn(w http.ResponseWriter, r *http.Request) { + user := h.chatUser(w, r) + if user == nil { + return + } + if h.Model == nil { + writeErr(w, http.StatusServiceUnavailable, "chat is not configured") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, chatTurnBodyMaxBytes) + request, err := decodeTurnRequest(r.Body) + if err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeErr(w, http.StatusRequestEntityTooLarge, "chat request body is too large") + } else { + writeErr(w, http.StatusBadRequest, "invalid body") + } + return + } + + conversationID := chatConversationID(r) + unlock := h.lockConversation(conversationID) + defer unlock() + conversation, err := h.Conversations.Get(r.Context(), user.ID, conversationID) + if errors.Is(err, chat.ErrConversationNotFound) { + writeErr(w, http.StatusNotFound, "conversation not found") + return + } + if err != nil { + slog.ErrorContext(r.Context(), "get chat conversation for turn", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to get conversation") + return + } + request.Messages = recoverAbandonedToolCalls(conversation.Messages, request.Messages) + if err := validateTurn(conversation.Messages, request.Messages); err != nil { + if errors.Is(err, errChatTurnTooLarge) { + writeErr(w, http.StatusRequestEntityTooLarge, err.Error()) + } else { + writeErr(w, http.StatusBadRequest, err.Error()) + } + return + } + if len(request.Messages) > 0 { + if err := h.Conversations.Append(r.Context(), user.ID, conversationID, request.Messages...); err != nil { + if errors.Is(err, chat.ErrConversationLimit) { + writeErr(w, http.StatusRequestEntityTooLarge, "conversation storage limit reached") + return + } + if errors.Is(err, chat.ErrConversationNotFound) { + writeErr(w, http.StatusNotFound, "conversation not found") + return + } + slog.ErrorContext(r.Context(), "append chat input", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to append chat input") + return + } + conversation, err = h.Conversations.Get(r.Context(), user.ID, conversationID) + if err != nil { + slog.ErrorContext(r.Context(), "reload chat conversation", "err", err, "user", user.ID) + writeErr(w, http.StatusInternalServerError, "failed to load conversation") + return + } + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + emit := func(event turnEvent) error { + if err := json.NewEncoder(w).Encode(event); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + return nil + } + message, err := h.Model.Complete(r.Context(), conversation.Messages, func(delta string) error { + return emit(turnEvent{Type: "content_delta", Delta: delta}) + }) + if err != nil { + slog.WarnContext(r.Context(), "complete chat turn", "err", err, "user", user.ID) + return + } + message.Role = chat.RoleAssistant + message.ID = "" + message.CreatedAt = time.Time{} + if err := h.Conversations.Append(r.Context(), user.ID, conversationID, message); err != nil { + if errors.Is(err, chat.ErrConversationLimit) { + slog.WarnContext(r.Context(), "chat response exceeded storage limit", "user", user.ID) + return + } + slog.ErrorContext(r.Context(), "append chat response", "err", err, "user", user.ID) + return + } + _ = emit(turnEvent{Type: "assistant", Message: &message}) +} + +func recoverAbandonedToolCalls(history, messages []chat.Message) []chat.Message { + outstanding := outstandingToolCalls(history) + if len(outstanding) == 0 || (len(messages) > 0 && messages[0].Role == chat.RoleTool) { + return messages + } + if len(messages) > 1 || (len(messages) == 1 && messages[0].Role != chat.RoleUser) { + return messages + } + + recovered := make([]chat.Message, 0, len(outstanding)+len(messages)) + for _, call := range history[len(history)-1].ToolCalls { + result, _ := json.Marshal(bashToolResult{ + Stdout: pointer(""), + Stderr: pointer("tool call abandoned before completion"), + ExitCode: pointer(1), + TimedOut: pointer(false), + Truncated: pointer(false), + }) + recovered = append(recovered, chat.Message{Role: chat.RoleTool, ToolCallID: call.ID, Content: string(result)}) + } + return append(recovered, messages...) +} + +func pointer[T any](value T) *T { + return &value +} + +func chatHistorySize(messages []chat.Message) int { + total := 0 + for _, message := range messages { + total += len(message.Content) + len(message.ToolCallID) + for _, call := range message.ToolCalls { + total += len(call.ID) + len(call.Type) + len(call.Function.Name) + len(call.Function.Arguments) + } + } + return total +} + +func validateTurn(history, messages []chat.Message) error { + if len(messages) > 32 || len(history)+len(messages)+1 > chatHistoryMaxCount { + return errChatTurnTooLarge + } + if chatHistorySize(history)+chatHistorySize(messages) > chatHistoryMaxBytes-chatMessageMaxBytes { + return errChatTurnTooLarge + } + for _, message := range messages { + if len(message.Content) > chatMessageMaxBytes { + return errChatTurnTooLarge + } + if message.ID != "" || !message.CreatedAt.IsZero() || message.Role == chat.RoleAssistant || message.Role == "" { + return errors.New("invalid client message") + } + if message.Role == chat.RoleUser { + if message.ToolCallID != "" || len(message.ToolCalls) != 0 || strings.TrimSpace(message.Content) == "" { + return errors.New("invalid user message") + } + continue + } + if message.Role != chat.RoleTool || message.ToolCallID == "" || len(message.ToolCalls) != 0 { + return errors.New("invalid tool message") + } + if err := validateBashToolResult(message.Content); err != nil { + return errors.New("invalid tool result") + } + } + + if len(messages) == 0 { + if len(history) == 0 || (history[len(history)-1].Role != chat.RoleUser && history[len(history)-1].Role != chat.RoleTool) { + return errors.New("empty retry is not allowed") + } + return nil + } + + outstanding := outstandingToolCalls(history) + if len(outstanding) == 0 { + if len(messages) != 1 || messages[0].Role != chat.RoleUser { + return errors.New("expected exactly one user message") + } + return nil + } + toolMessages := messages + if len(messages) == len(outstanding)+1 && messages[len(messages)-1].Role == chat.RoleUser { + toolMessages = messages[:len(messages)-1] + } + if len(toolMessages) != len(outstanding) { + return errors.New("tool results must match outstanding tool calls") + } + seen := make(map[string]struct{}, len(toolMessages)) + for _, message := range toolMessages { + if message.Role != chat.RoleTool { + return errors.New("tool results must match outstanding tool calls") + } + if _, ok := outstanding[message.ToolCallID]; !ok { + return errors.New("tool result does not match an outstanding tool call") + } + if _, duplicate := seen[message.ToolCallID]; duplicate { + return errors.New("duplicate tool result") + } + seen[message.ToolCallID] = struct{}{} + } + return nil +} + +func outstandingToolCalls(history []chat.Message) map[string]struct{} { + if len(history) == 0 || history[len(history)-1].Role != chat.RoleAssistant { + return nil + } + calls := history[len(history)-1].ToolCalls + if len(calls) == 0 { + return nil + } + outstanding := make(map[string]struct{}, len(calls)) + for _, call := range calls { + outstanding[call.ID] = struct{}{} + } + return outstanding +} + +func chatConversationID(r *http.Request) string { + if id := r.PathValue("id"); id != "" { + return id + } + const prefix = "/api/chat/conversations/" + path := strings.TrimPrefix(r.URL.Path, prefix) + return strings.TrimSuffix(path, "/turns") +} + +func decodeTurnRequest(body io.Reader) (TurnRequest, error) { + var raw struct { + Messages *json.RawMessage `json:"messages"` + } + decoder := json.NewDecoder(body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&raw); err != nil { + return TurnRequest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return TurnRequest{}, errors.New("multiple JSON values") + } + + if raw.Messages == nil { + return TurnRequest{}, errors.New("messages is required") + } + var rawMessages []json.RawMessage + if err := json.Unmarshal(*raw.Messages, &rawMessages); err != nil || rawMessages == nil { + return TurnRequest{}, errors.New("messages must be an array") + } + + request := TurnRequest{Messages: make([]chat.Message, 0, len(rawMessages))} + for _, rawMessage := range rawMessages { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawMessage, &fields); err != nil { + return TurnRequest{}, err + } + if _, ok := fields["id"]; ok { + return TurnRequest{}, errors.New("client message id is not allowed") + } + if _, ok := fields["created_at"]; ok { + return TurnRequest{}, errors.New("client message timestamp is not allowed") + } + + var message chat.Message + messageDecoder := json.NewDecoder(bytes.NewReader(rawMessage)) + messageDecoder.DisallowUnknownFields() + if err := messageDecoder.Decode(&message); err != nil { + return TurnRequest{}, err + } + request.Messages = append(request.Messages, message) + } + return request, nil +} + +type bashToolResult struct { + Stdout *string `json:"stdout"` + Stderr *string `json:"stderr"` + ExitCode *int `json:"exit_code"` + TimedOut *bool `json:"timed_out"` + Truncated *bool `json:"truncated"` +} + +func validateBashToolResult(content string) error { + var result bashToolResult + decoder := json.NewDecoder(strings.NewReader(content)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&result); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return errors.New("trailing JSON") + } + if result.Stdout == nil || result.Stderr == nil || result.ExitCode == nil || result.TimedOut == nil || result.Truncated == nil { + return errors.New("missing BashToolResult field") + } + return nil +} diff --git a/libs/fleet/backend/handlers/chat_locks.go b/libs/fleet/backend/handlers/chat_locks.go new file mode 100644 index 0000000000..b36cab300a --- /dev/null +++ b/libs/fleet/backend/handlers/chat_locks.go @@ -0,0 +1,48 @@ +package handlers + +import "sync" + +type conversationLock struct { + mu sync.Mutex + refs int +} + +type conversationLockRegistry struct { + mu sync.Mutex + locks map[string]*conversationLock +} + +func newConversationLockRegistry() *conversationLockRegistry { + return &conversationLockRegistry{locks: make(map[string]*conversationLock)} +} + +func (registry *conversationLockRegistry) lock(conversationID string) func() { + registry.mu.Lock() + entry := registry.locks[conversationID] + if entry == nil { + entry = &conversationLock{} + registry.locks[conversationID] = entry + } + entry.refs++ + registry.mu.Unlock() + + entry.mu.Lock() + return func() { + entry.mu.Unlock() + registry.mu.Lock() + entry.refs-- + if entry.refs == 0 && registry.locks[conversationID] == entry { + delete(registry.locks, conversationID) + } + registry.mu.Unlock() + } +} + +var fallbackConversationLocks = newConversationLockRegistry() + +func (h Handlers) lockConversation(conversationID string) func() { + if h.chatLocks == nil { + return fallbackConversationLocks.lock(conversationID) + } + return h.chatLocks.lock(conversationID) +} diff --git a/libs/fleet/backend/handlers/chat_test.go b/libs/fleet/backend/handlers/chat_test.go new file mode 100644 index 0000000000..8fb0a58134 --- /dev/null +++ b/libs/fleet/backend/handlers/chat_test.go @@ -0,0 +1,520 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "cyclops-cs-backend/auth" + "cyclops-cs-backend/chat" +) + +type fakeModel struct { + responses []chat.Message + histories [][]chat.Message +} + +var alice = &auth.User{ID: "user-alice", AZP: "cyclops-cs-spa"} + +func newReq(method, target, body string, user *auth.User) *http.Request { + request := httptest.NewRequest(method, target, strings.NewReader(body)) + parts := strings.Split(strings.Trim(target, "/"), "/") + if len(parts) >= 4 && parts[0] == "api" && parts[1] == "chat" && parts[2] == "conversations" { + request.SetPathValue("id", parts[3]) + } + if user != nil { + request = withUser(request, user) + } + return request +} + +func (f *fakeModel) Complete(_ context.Context, messages []chat.Message, onDelta func(string) error) (chat.Message, error) { + f.histories = append(f.histories, append([]chat.Message(nil), messages...)) + response := f.responses[0] + f.responses = f.responses[1:] + if response.Content != "" { + _ = onDelta(response.Content) + } + return response, nil +} + +func newChatHandlers(responses ...chat.Message) (Handlers, *chat.MemoryConversationStore, *fakeModel) { + store := chat.NewMemoryConversationStore() + model := &fakeModel{responses: responses} + return Handlers{ChatEnabled: true, Conversations: store, Model: model, chatLocks: newConversationLockRegistry()}, store, model +} + +func createChatConversation(t *testing.T, h Handlers, user *auth.User) *chat.Conversation { + t.Helper() + w := httptest.NewRecorder() + h.CreateConversation(w, newReq(http.MethodPost, "/api/chat/conversations", "", user)) + if w.Code != http.StatusCreated { + t.Fatalf("create status = %d, want 201; body = %s", w.Code, w.Body.String()) + } + var conversation chat.Conversation + if err := json.Unmarshal(w.Body.Bytes(), &conversation); err != nil { + t.Fatalf("decode conversation: %v", err) + } + return &conversation +} + +func TestChatConversationsCreateListGetOwnership(t *testing.T) { + h, _, _ := newChatHandlers() + conversation := createChatConversation(t, h, alice) + w := httptest.NewRecorder() + h.ListConversations(w, newReq(http.MethodGet, "/api/chat/conversations", "", alice)) + if w.Code != http.StatusOK { + t.Fatalf("list status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + var summaries []chat.ConversationSummary + if err := json.Unmarshal(w.Body.Bytes(), &summaries); err != nil { + t.Fatalf("decode summaries: %v", err) + } + if len(summaries) != 1 || summaries[0].ID != conversation.ID { + t.Fatalf("summaries = %#v", summaries) + } + w = httptest.NewRecorder() + h.GetConversation(w, newReq(http.MethodGet, "/api/chat/conversations/"+conversation.ID, "", alice)) + if w.Code != http.StatusOK { + t.Fatalf("get status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + w = httptest.NewRecorder() + h.GetConversation(w, newReq(http.MethodGet, "/api/chat/conversations/"+conversation.ID, "", &auth.User{ID: "user-bob"})) + if w.Code != http.StatusNotFound { + t.Fatalf("cross-owner get status = %d, want 404; body = %s", w.Code, w.Body.String()) + } + w = httptest.NewRecorder() + h.GetConversation(w, newReq(http.MethodGet, "/api/chat/conversations/missing", "", alice)) + if w.Code != http.StatusNotFound { + t.Fatalf("missing get status = %d, want 404; body = %s", w.Code, w.Body.String()) + } +} + +func TestChatRequiresEnabledFeatureAndUser(t *testing.T) { + w := httptest.NewRecorder() + Handlers{}.ListConversations(w, newReq(http.MethodGet, "/api/chat/conversations", "", alice)) + if w.Code != http.StatusNotFound { + t.Fatalf("disabled status = %d, want 404", w.Code) + } + h, _, _ := newChatHandlers() + for _, user := range []*auth.User{nil, &auth.User{}} { + w = httptest.NewRecorder() + h.ListConversations(w, newReq(http.MethodGet, "/api/chat/conversations", "", user)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("user %#v status = %d, want 401", user, w.Code) + } + } +} + +func TestChatTurnRejectsInvalidClientMessages(t *testing.T) { + cases := map[string]string{ + "assistant": `{"messages":[{"role":"assistant","content":"no"}]}`, + "client id": `{"messages":[{"id":"client-id","role":"user","content":"hello"}]}`, + "client timestamp": `{"messages":[{"role":"user","content":"hello","created_at":"2026-01-01T00:00:00Z"}]}`, + "user tool call": `{"messages":[{"role":"user","content":"hello","tool_calls":[{"id":"call-1"}]}]}`, + "empty client id": `{"messages":[{"id":"","role":"user","content":"hello"}]}`, + "tool missing call id": `{"messages":[{"role":"tool","content":"output"}]}`, + "zero client timestamp": `{"messages":[{"role":"user","content":"hello","created_at":"0001-01-01T00:00:00Z"}]}`, + "tool has tool calls": `{"messages":[{"role":"tool","tool_call_id":"call-1","tool_calls":[{"id":"call-1"}]}]}`, + "multiple user messages": `{"messages":[{"role":"user","content":"one"},{"role":"user","content":"two"}]}`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + h, _, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestChatTurnStreamsAndPersistsMessages(t *testing.T) { + h, store, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "hello"}) + conversation := createChatConversation(t, h, alice) + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"hi"}]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if got, want := w.Header().Get("Content-Type"), "application/x-ndjson"; got != want { + t.Fatalf("Content-Type = %q, want %q", got, want) + } + if got, want := w.Body.String(), "{\"type\":\"content_delta\",\"delta\":\"hello\"}\n{\"type\":\"assistant\",\"message\":{\"role\":\"assistant\",\"content\":\"hello\"}}\n"; got != want { + t.Fatalf("stream = %q, want %q", got, want) + } + stored, err := store.Get(context.Background(), alice.ID, conversation.ID) + if err != nil { + t.Fatal(err) + } + if len(stored.Messages) != 2 || stored.Messages[0].Role != chat.RoleUser || stored.Messages[0].Content != "hi" || stored.Messages[1].Role != chat.RoleAssistant || stored.Messages[1].Content != "hello" { + t.Fatalf("stored messages = %#v", stored.Messages) + } +} + +func TestChatTurnAbandonsOutstandingToolCallsBeforeUserMessage(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "continued"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleAssistant, ToolCalls: []chat.ToolCall{{ID: "call-1", Type: "function", Function: chat.ToolFunction{Name: "run_bash", Arguments: `{"command":"pwd"}`}}}}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"skip tools"}]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 1 || len(model.histories[0]) != 3 || model.histories[0][1].Role != chat.RoleTool || model.histories[0][2].Content != "skip tools" { + t.Fatalf("model history = %#v", model.histories) + } +} + +func TestChatTurnRequiresExactToolResultsAndSendsCompleteHistory(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "complete"}) + conversation := createChatConversation(t, h, alice) + toolCalls := []chat.ToolCall{{ID: "call-1", Type: "function", Function: chat.ToolFunction{Name: "run_bash", Arguments: `{"command":"pwd"}`}}, {ID: "call-2", Type: "function", Function: chat.ToolFunction{Name: "run_bash", Arguments: `{"command":"ls"}`}}} + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleAssistant, ToolCalls: toolCalls}); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "missing": `{"messages":[{"role":"tool","tool_call_id":"call-1","content":"/tmp"}]}`, + "duplicate": `{"messages":[{"role":"tool","tool_call_id":"call-1","content":"/tmp"},{"role":"tool","tool_call_id":"call-1","content":"/tmp"}]}`, + "extra": `{"messages":[{"role":"tool","tool_call_id":"call-1","content":"/tmp"},{"role":"tool","tool_call_id":"extra","content":"no"}]}`, + "mixed user": `{"messages":[{"role":"tool","tool_call_id":"call-1","content":"/tmp"},{"role":"user","content":"no"}]}`, + } { + t.Run(name, func(t *testing.T) { + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + }) + } + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"messages":[{"role":"tool","tool_call_id":"call-1","content":%q},{"role":"tool","tool_call_id":"call-2","content":%q}]}`, validBashToolResult, validBashToolResult) + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 1 || len(model.histories[0]) != 3 { + t.Fatalf("model histories = %#v", model.histories) + } + if got := model.histories[0][1:]; got[0].ToolCallID != "call-1" || got[1].ToolCallID != "call-2" { + t.Fatalf("model tool history = %#v", got) + } +} + +func TestChatTurnAllowsEmptyRetryOnlyAfterUserOrTool(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "retry user"}, chat.Message{Role: chat.RoleAssistant, Content: "retry tool"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleUser, Content: "retry"}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("user retry status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + conversation = createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleTool, ToolCallID: "call-1", Content: "retry"}); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("tool retry status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + conversation = createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleAssistant, Content: "already answered"}); err != nil { + t.Fatal(err) + } + w = httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[]}`, alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("assistant retry status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 2 { + t.Fatalf("model calls = %d, want 2", len(model.histories)) + } +} + +func TestChatTurnRejectsMalformedJSON(t *testing.T) { + h, _, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", strings.Repeat("{", 1), alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } +} + +func TestChatTurnRequiresMessagesArray(t *testing.T) { + for name, body := range map[string]string{ + "missing": `{}`, + "null": `{"messages":null}`, + } { + t.Run(name, func(t *testing.T) { + h, store, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleUser, Content: "retry"}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + }) + } +} + +const validBashToolResult = `{"stdout":"/tmp\n","stderr":"","exit_code":0,"timed_out":false,"truncated":false}` + +func toolResultTurnBody(toolCallID, content string) string { + return fmt.Sprintf(`{"messages":[{"role":"tool","tool_call_id":%q,"content":%q}]}`, toolCallID, content) +} + +func TestChatTurnRejectsMalformedBashToolResults(t *testing.T) { + cases := map[string]string{ + "non-JSON": "output", + "missing field": `{"stdout":"","stderr":"","exit_code":0,"timed_out":false}`, + "wrong string": `{"stdout":1,"stderr":"","exit_code":0,"timed_out":false,"truncated":false}`, + "wrong integer": `{"stdout":"","stderr":"","exit_code":"0","timed_out":false,"truncated":false}`, + "wrong boolean": `{"stdout":"","stderr":"","exit_code":0,"timed_out":"false","truncated":false}`, + "unknown field": `{"stdout":"","stderr":"","exit_code":0,"timed_out":false,"truncated":false,"extra":true}`, + "trailing JSON": validBashToolResult + ` {}`, + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleAssistant, ToolCalls: []chat.ToolCall{{ID: "call-1"}}}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", toolResultTurnBody("call-1", content), alice)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String()) + } + stored, err := store.Get(context.Background(), alice.ID, conversation.ID) + if err != nil { + t.Fatal(err) + } + if len(stored.Messages) != 1 || len(model.histories) != 0 { + t.Fatalf("malformed tool result persisted or reached model: messages=%#v histories=%#v", stored.Messages, model.histories) + } + }) + } +} + +func TestChatTurnAcceptsValidBashToolResult(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "complete"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{Role: chat.RoleAssistant, ToolCalls: []chat.ToolCall{{ID: "call-1"}}}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", toolResultTurnBody("call-1", validBashToolResult), alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 1 || len(model.histories[0]) != 2 { + t.Fatalf("model histories = %#v", model.histories) + } +} + +type blockingSequentialModel struct { + mu sync.Mutex + histories [][]chat.Message + calls int + firstStarted chan struct{} + secondStarted chan struct{} + releaseFirst chan struct{} +} + +func (m *blockingSequentialModel) Complete(_ context.Context, messages []chat.Message, _ func(string) error) (chat.Message, error) { + m.mu.Lock() + m.calls++ + call := m.calls + m.histories = append(m.histories, append([]chat.Message(nil), messages...)) + m.mu.Unlock() + + if call == 1 { + close(m.firstStarted) + <-m.releaseFirst + } else if call == 2 { + close(m.secondStarted) + } + return chat.Message{Role: chat.RoleAssistant, Content: fmt.Sprintf("assistant-%d", call)}, nil +} + +func TestChatTurnSerializesConcurrentTurnsPerConversation(t *testing.T) { + store := chat.NewMemoryConversationStore() + model := &blockingSequentialModel{ + firstStarted: make(chan struct{}), + secondStarted: make(chan struct{}), + releaseFirst: make(chan struct{}), + } + h := Handlers{ChatEnabled: true, Conversations: store, Model: model} + conversation := createChatConversation(t, h, alice) + + statuses := make(chan int, 2) + go func() { + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"first"}]}`, alice)) + statuses <- w.Code + }() + <-model.firstStarted + go func() { + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"second"}]}`, alice)) + statuses <- w.Code + }() + + select { + case <-model.secondStarted: + close(model.releaseFirst) + <-statuses + <-statuses + t.Fatal("second turn reached the model before the first turn completed") + case <-time.After(100 * time.Millisecond): + } + close(model.releaseFirst) + if first, second := <-statuses, <-statuses; first != http.StatusOK || second != http.StatusOK { + t.Fatalf("statuses = %d, %d; want 200, 200", first, second) + } + + model.mu.Lock() + histories := append([][]chat.Message(nil), model.histories...) + model.mu.Unlock() + if len(histories) != 2 || len(histories[1]) != 3 { + t.Fatalf("histories = %#v", histories) + } + if histories[1][0].Content != "first" || histories[1][1].Role != chat.RoleAssistant || histories[1][1].Content != "assistant-1" || histories[1][2].Content != "second" { + t.Fatalf("second model history = %#v", histories[1]) + } +} + +func TestChatTurnRecoversAbandonedToolCallsBeforeNewPrompt(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "recovered"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{ + Role: chat.RoleAssistant, + ToolCalls: []chat.ToolCall{{ID: "call-1", Type: "function", Function: chat.ToolFunction{Name: "bash", Arguments: `{"command":"sleep 60"}`}}}, + }); err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"continue without it"}]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 1 || len(model.histories[0]) != 3 { + t.Fatalf("model history = %#v", model.histories) + } + if model.histories[0][1].Role != chat.RoleTool || model.histories[0][1].ToolCallID != "call-1" || !strings.Contains(model.histories[0][1].Content, "abandoned") { + t.Fatalf("synthetic tool result = %#v", model.histories[0][1]) + } + if model.histories[0][2].Role != chat.RoleUser || model.histories[0][2].Content != "continue without it" { + t.Fatalf("new prompt = %#v", model.histories[0][2]) + } +} + +func TestChatTurnRetriesAbandonedToolCalls(t *testing.T) { + h, store, model := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "recovered"}) + conversation := createChatConversation(t, h, alice) + if err := store.Append(context.Background(), alice.ID, conversation.ID, chat.Message{ + Role: chat.RoleAssistant, + ToolCalls: []chat.ToolCall{{ID: "call-1", Type: "function", Function: chat.ToolFunction{Name: "bash", Arguments: `{"command":"bad"}`}}}, + }); err != nil { + t.Fatal(err) + } + + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[]}`, alice)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String()) + } + if len(model.histories) != 1 || len(model.histories[0]) != 2 || model.histories[0][1].Role != chat.RoleTool { + t.Fatalf("model history = %#v", model.histories) + } +} + +func TestChatTurnRejectsOversizedBodyAndMessages(t *testing.T) { + t.Run("body", func(t *testing.T) { + h, _, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + body := `{"messages":[{"role":"user","content":"` + strings.Repeat("x", 300<<10) + `"}]}` + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413; body = %s", w.Code, w.Body.String()) + } + }) + + t.Run("message", func(t *testing.T) { + h, _, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + body := `{"messages":[{"role":"user","content":"` + strings.Repeat("x", 140<<10) + `"}]}` + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", body, alice)) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413; body = %s", w.Code, w.Body.String()) + } + }) +} + +func TestChatTurnRejectsConversationBeyondHistoryLimit(t *testing.T) { + h, store, _ := newChatHandlers(chat.Message{Role: chat.RoleAssistant, Content: "unused"}) + conversation := createChatConversation(t, h, alice) + messages := make([]chat.Message, 256) + for index := range messages { + messages[index] = chat.Message{Role: chat.RoleUser, Content: "message"} + } + if err := store.Append(context.Background(), alice.ID, conversation.ID, messages...); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + h.CreateTurn(w, newReq(http.MethodPost, "/api/chat/conversations/"+conversation.ID+"/turns", `{"messages":[{"role":"user","content":"one more"}]}`, alice)) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413; body = %s", w.Code, w.Body.String()) + } +} + +func TestConversationLockRegistryEvictsReleasedLocks(t *testing.T) { + registry := newConversationLockRegistry() + for index := 0; index < 100; index++ { + unlock := registry.lock(fmt.Sprintf("missing-%d", index)) + unlock() + } + registry.mu.Lock() + defer registry.mu.Unlock() + if len(registry.locks) != 0 { + t.Fatalf("retained locks = %d, want 0", len(registry.locks)) + } +} + +func TestCreateConversationReportsStoreLimit(t *testing.T) { + store := chat.NewMemoryConversationStore() + h := Handlers{ChatEnabled: true, Conversations: store, Model: &fakeModel{}} + for index := 0; index < 100; index++ { + w := httptest.NewRecorder() + h.CreateConversation(w, newReq(http.MethodPost, "/api/chat/conversations", "", alice)) + if w.Code != http.StatusCreated { + t.Fatalf("create %d status = %d; body = %s", index, w.Code, w.Body.String()) + } + } + w := httptest.NewRecorder() + h.CreateConversation(w, newReq(http.MethodPost, "/api/chat/conversations", "", alice)) + if w.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429; body = %s", w.Code, w.Body.String()) + } +} diff --git a/libs/fleet/backend/handlers/config.go b/libs/fleet/backend/handlers/config.go index a84212672c..2b8234234f 100644 --- a/libs/fleet/backend/handlers/config.go +++ b/libs/fleet/backend/handlers/config.go @@ -16,6 +16,7 @@ type ConfigResponse struct { // server-side by authz.rego. Admin bool `json:"admin"` Billing bool `json:"billing"` + Chat bool `json:"chat"` } // GetConfig returns per-user feature flags evaluated by OPA. @@ -46,5 +47,5 @@ func (h Handlers) GetConfig(w http.ResponseWriter, r *http.Request) { billingEnabled = false } - writeJSON(w, http.StatusOK, ConfigResponse{Admin: isAdmin, Billing: billingEnabled}) + writeJSON(w, http.StatusOK, ConfigResponse{Admin: isAdmin, Billing: billingEnabled, Chat: h.ChatEnabled}) } diff --git a/libs/fleet/backend/handlers/handlers.go b/libs/fleet/backend/handlers/handlers.go index 5608884fd6..eecb06abc3 100644 --- a/libs/fleet/backend/handlers/handlers.go +++ b/libs/fleet/backend/handlers/handlers.go @@ -11,6 +11,7 @@ import ( "regexp" "cyclops-cs-backend/auth" + "cyclops-cs-backend/chat" "cyclops-cs-backend/config" "cyclops-cs-backend/githubtrust" "cyclops-cs-backend/keycloak" @@ -33,6 +34,11 @@ type Handlers struct { Readiness *Readiness + ChatEnabled bool + Conversations chat.ConversationStore + Model chat.ModelClient + chatLocks *conversationLockRegistry + // WorkloadAdmin manages per-tenant clients in the workloads realm so // OSGym pool VMs can obtain a tenant-scoped OIDC token. nil disables // the feature (CreateNamespace then skips OIDC credential provisioning). @@ -50,6 +56,8 @@ func New(admin *keycloak.Admin, cfg *config.Configuration) Handlers { AuthCfg: cfg.Auth, KC: cfg.Keycloak, Stripe: cfg.Stripe, + ChatEnabled: cfg.Chat.Enabled, + chatLocks: newConversationLockRegistry(), } } diff --git a/libs/fleet/backend/main.go b/libs/fleet/backend/main.go index d202fa0447..aa37ea7cee 100644 --- a/libs/fleet/backend/main.go +++ b/libs/fleet/backend/main.go @@ -30,6 +30,7 @@ import ( "cyclops-cs-backend/auth" "cyclops-cs-backend/billing" + "cyclops-cs-backend/chat" "cyclops-cs-backend/config" "cyclops-cs-backend/database" _ "cyclops-cs-backend/docs" // generated by `swag init` @@ -131,6 +132,15 @@ func setupRouter(c handlers.Handlers) http.Handler { r.Handle("GET /api/config", withAuthenticatedMiddlewares("/api/config", c.GetConfig)) + r.Handle("POST /api/chat/conversations", + withAuthenticatedMiddlewares("/api/chat/conversations", c.CreateConversation)) + r.Handle("GET /api/chat/conversations", + withAuthenticatedMiddlewares("/api/chat/conversations", c.ListConversations)) + r.Handle("GET /api/chat/conversations/{id}", + withAuthenticatedMiddlewares("/api/chat/conversations/{id}", c.GetConversation)) + r.Handle("POST /api/chat/conversations/{id}/turns", + withAuthenticatedMiddlewares("/api/chat/conversations/{id}/turns", c.CreateTurn)) + r.Handle("QUERY /api/state/query", withAuthenticatedMiddlewares("/api/state/query", c.QueryState)) @@ -361,6 +371,12 @@ func run() error { slog.Info("github trust policies: disabled (database schema unavailable)") } + if cfg.Chat.Enabled { + h.Conversations = chat.NewMemoryConversationStore() + h.Model = chat.NewLiteLLMClient(cfg.Chat.BaseURL, cfg.Chat.APIKey, cfg.Chat.Model) + slog.Info("chat: enabled", "base_url", cfg.Chat.BaseURL, "model", cfg.Chat.Model) + } + router := setupRouter(h) srv := &http.Server{Addr: cfg.WebServer.Addr, Handler: router} diff --git a/libs/fleet/backend/main_test.go b/libs/fleet/backend/main_test.go index 637ed7da81..3cfd7f3a46 100644 --- a/libs/fleet/backend/main_test.go +++ b/libs/fleet/backend/main_test.go @@ -427,3 +427,34 @@ func TestK8sRouteRejectsDisallowedPoolBeforeProxy(t *testing.T) { t.Fatal("disallowed pool request reached kubectl proxy") } } + +func TestSwaggerIncludesChatRoutes(t *testing.T) { + data, err := os.ReadFile("docs/swagger.json") + if err != nil { + t.Fatal(err) + } + var spec struct { + Paths map[string]map[string]struct{} `json:"paths"` + } + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("unmarshal swagger.json: %v", err) + } + for _, tc := range []struct{ path, method string }{ + {"/api/chat/conversations", "post"}, {"/api/chat/conversations", "get"}, + {"/api/chat/conversations/{id}", "get"}, {"/api/chat/conversations/{id}/turns", "post"}, + } { + if _, ok := spec.Paths[tc.path][tc.method]; !ok { + t.Fatalf("swagger.json missing %s %s", strings.ToUpper(tc.method), tc.path) + } + } +} + +func TestNginxRoutesChatToBackend(t *testing.T) { + data, err := os.ReadFile("../nginx.conf") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "|chat|") && !strings.Contains(string(data), "|chat)") { + t.Fatal("nginx.conf backend matcher does not include chat") + } +} diff --git a/libs/fleet/docs/browser-agent.md b/libs/fleet/docs/browser-agent.md new file mode 100644 index 0000000000..e106b57980 --- /dev/null +++ b/libs/fleet/docs/browser-agent.md @@ -0,0 +1,153 @@ +# Browser bash chat + +Cyclops includes an authenticated chat UI whose agent loop runs inside the +browser. The model may request a single `bash` function tool implemented with +`just-bash/browser`; the browser executes it in a temporary virtual filesystem +and returns the result to the backend for the next model turn. + +The Go backend owns conversation history, authentication, authorization, and +LiteLLM access. The current conversation store is in memory, so history is lost +when the backend process restarts. The store interface is intended to support a +PostgreSQL implementation later. + +## Security boundary + +The bash runtime is browser-local and isolated: + +- It cannot access the host filesystem. +- It cannot make network requests. +- Its virtual filesystem is temporary and exists only in browser memory. +- Each conversation receives a separate shell and virtual filesystem. +- Refreshing or closing the page discards the browser-local filesystem, while + persisted transcript history remains available until the backend restarts. +- Tool-provided `timeout_ms` and `max_output_chars` values are clamped by the + browser runtime before execution. +- Requests are capped at 256 KiB, individual messages at 128 KiB, retained + conversations at 256 messages and 1 MiB of model context, and streamed model + output at 128 KiB. The in-memory backend also caps each owner at 100 + conversations and the process-wide transcript store at 64 MiB. + +There is no MCP integration, Playwright tool, browser automation, Chromium +process, or Node sidecar. The frontend does not receive LiteLLM credentials. + +## Local development + +Install dependencies and run the frontend: + +```bash +cd cyclops-cs +corepack pnpm install +corepack pnpm dev +``` + +Run the Go backend with chat enabled in a separate terminal: + +```bash +cd cyclops-cs/backend +CYCLOPS_CS_CHAT_ENABLED=true \ +LITELLM_BASE_URL=http://localhost:4000/v1 \ +LITELLM_MODEL=large \ +LITELLM_API_KEY='' \ +go run . +``` + +Use an OpenAI-compatible LiteLLM endpoint and a restricted virtual key. Never +put `LITELLM_API_KEY` in Vite variables, frontend configuration, or browser +storage. + +## Backend configuration + +- `CYCLOPS_CS_CHAT_ENABLED` enables the Chat navigation item and API handlers. +- `LITELLM_BASE_URL` is the OpenAI-compatible API base URL, including `/v1`. +- `LITELLM_MODEL` selects the model alias and defaults to `large`. +- `LITELLM_API_KEY` is the backend-only LiteLLM virtual key. + +When chat is enabled, both `LITELLM_BASE_URL` and `LITELLM_API_KEY` are +required. + +## API routes + +All routes require the existing Cyclops authentication and authorization +middleware: + +- `POST /api/chat/conversations` creates a conversation. +- `GET /api/chat/conversations` lists the calling user's conversations. +- `GET /api/chat/conversations/{id}` loads one owned conversation. +- `POST /api/chat/conversations/{id}/turns` appends user or tool messages and + streams the assistant response as server-sent events. + +The backend stores user, assistant, tool-call, and tool-result messages. The +React UI keeps only transient rendering and run state; it reloads canonical +conversation history from the backend. If a browser run is stopped or rejects a +tool call after the assistant request is stored, the next retry or user prompt +records synthetic failed tool results before continuing, so the conversation is +not stranded. + +## Preview environments + +Chat is enabled only in the preview base at +`clusters/kopf-k3s/cyclops-cs-previews-base`. Each preview has a Flux +`GitRepository` pinned to the PR head SHA, so manifest and secret-wiring changes +are rendered from the same commit as the frontend/backend images. The backend +sidecar receives the four LiteLLM environment variables. The nginx/frontend +container receives none of them. + +The `cyclops-cs` pull-request label is the trust gate for this backend secret. +Maintainers must apply it only to reviewed, same-repository PRs whose backend +code is trusted to receive the preview credential. Fork PRs are blocked from +publishing Cyclops images, and a base-branch `pull_request_target` workflow +automatically removes the label if it is applied to a fork PR. Remove the label +and revoke the credential if any enabled preview becomes untrusted. + +An `ExternalSecret` named `cyclops-cs-litellm` reads property `api_key` from +AWS Secrets Manager path `kopf-k3s/cyclops-cs-browser-agent-litellm` and creates +a namespace-local Secret with the same name. Production manifests are not +changed by this preview configuration. + +The stored value must be a dedicated LiteLLM virtual key restricted to model +alias `large`. Configure a strict spend budget and an expiry, or use a documented +short rotation interval if expiry is unavailable. Rotate the key on schedule +and revoke it immediately if an enabled preview is no longer trusted. Never use +a general-purpose or unrestricted gateway key for previews. + +## Validation + +Before adding preview wiring, the source-tree assertion was: + +```bash +rg -n "CYCLOPS_CS_CHAT_ENABLED|LITELLM_BASE_URL|LITELLM_API_KEY|litellm-credentials.yaml" \ + clusters/kopf-k3s/cyclops-cs-previews-base +``` + +Expected baseline before wiring: no matches and exit status 1, proving the +preview chat configuration was not already present. + +Validate the browser agent and UI: + +```bash +cd cyclops-cs +corepack pnpm test:agent +corepack pnpm test:e2e -- agent-chat.spec.ts +corepack pnpm typecheck +corepack pnpm build +``` + +Verify the preview trust contract: + +```bash +clusters/kopf-k3s/cyclops-cs-previews/tests/security-contract.sh +``` + +Render and inspect the preview manifests: + +```bash +kubectl kustomize clusters/kopf-k3s/cyclops-cs-previews-base \ + > /tmp/cyclops-chat-preview.yaml +rg -n \ + "kind: ExternalSecret|CYCLOPS_CS_CHAT_ENABLED|LITELLM_BASE_URL|LITELLM_MODEL|LITELLM_API_KEY" \ + /tmp/cyclops-chat-preview.yaml +``` + +Confirm the rendered environment variables appear only on +`cyclops-cs-backend`, the ExternalSecret references the expected AWS path and +property, and no secret value is present in the output. diff --git a/libs/fleet/e2e/agent-chat.spec.ts b/libs/fleet/e2e/agent-chat.spec.ts new file mode 100644 index 0000000000..338ce54c0d --- /dev/null +++ b/libs/fleet/e2e/agent-chat.spec.ts @@ -0,0 +1,636 @@ +import { expect, test } from "@playwright/test" + +import { mockAuth, mockChatApi } from "./fixtures/mock-api" + +test("shows chat history, transcript region, prompt shell, and authenticated history time", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page) + + await page.goto("/agent") + + await expect(page.getByRole("heading", { name: "Conversations" })).toBeVisible() + await expect(page.getByText("Today")).toBeVisible() + await expect(page.getByRole("region", { name: "Chat" })).toBeVisible() + await expect(page.getByPlaceholder("Ask a question")).toBeVisible() + await expect(page.locator(".agent-chat-history [title]").first()).toHaveAttribute( + "title", + /.+/, + ) + expect(chat.authorizationHeaders.length).toBeGreaterThan(0) + expect(chat.authorizationHeaders).toEqual( + expect.arrayContaining([expect.stringMatching(/^Bearer /)]), + ) +}) + +test("keeps the prompt visible while conversation history loads", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { holdList: true }) + + await page.goto("/agent") + + await expect(page.getByTestId("conversation-skeleton")).toHaveCount(3) + await expect(page.getByPlaceholder("Ask a question")).toBeVisible() + chat.releaseList() +}) + +test("loads a selected conversation with message skeletons and accessible author times", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { holdConversation: true }) + + await page.goto("/agent") + await page.getByRole("button", { name: "Example browser task" }).click() + + await expect(page.getByTestId("message-skeleton")).toHaveCount(2) + await expect(page.getByPlaceholder("Ask a question")).toBeVisible() + chat.releaseConversation() + + await expect(page.getByText("Open the example site.")).toBeVisible() + await expect(page.getByText("The example site is ready.")).toBeVisible() + await expect(page.getByLabel(/^You at /)).toBeVisible() + await expect(page.getByLabel(/^Assistant at /)).toBeVisible() +}) + +test("does not redirect while chat feature configuration is pending", async ({ page }) => { + const auth = await mockAuth(page, { admin: false, chat: true }, { holdConfig: true }) + await mockChatApi(page) + + await page.goto("/agent") + await expect(page).toHaveURL(/\/agent$/) + await expect(page.getByRole("heading", { name: "Conversations" })).toHaveCount(0) + + auth.releaseConfig() + await expect(page.getByRole("heading", { name: "Conversations" })).toBeVisible() +}) + +test("manages mobile history modal focus, keyboard, inert background, and close restoration", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page) + + await page.goto("/agent") + + const trigger = page.getByRole("button", { name: "View conversations" }) + await expect(trigger).toBeVisible() + await expect(page.getByRole("dialog", { name: "Conversations" })).toHaveCount(0) + + await trigger.focus() + await trigger.press("Enter") + const dialog = page.getByRole("dialog", { name: "Conversations" }) + const closeButton = dialog.getByRole("button", { name: "Close conversations" }) + await expect(dialog).toBeVisible() + await expect(closeButton).toBeFocused() + await expect(dialog.getByRole("heading", { name: "Conversations", exact: true })).toHaveCount(1) + await expect(page.locator("#root")).toHaveAttribute("inert", "") + const poolsNavigation = page.getByRole("link", { name: "Pools", exact: true }).first() + await poolsNavigation.focus() + await expect(poolsNavigation).not.toBeFocused() + await expect(closeButton).toBeFocused() + + const lastFocusable = dialog.locator('button:not([disabled])').last() + await closeButton.press("Shift+Tab") + await expect(lastFocusable).toBeFocused() + await lastFocusable.press("Tab") + await expect(closeButton).toBeFocused() + + await page.keyboard.press("Escape") + await expect(page.getByRole("dialog", { name: "Conversations" })).toHaveCount(0) + await expect(trigger).toBeFocused() + await expect(page.locator("#root")).not.toHaveAttribute("inert", "") + + await trigger.click() + await page.getByTestId("conversation-history-backdrop").click({ position: { x: 380, y: 420 } }) + await expect(page.getByRole("dialog", { name: "Conversations" })).toHaveCount(0) + await expect(trigger).toBeFocused() + + await trigger.click() + await closeButton.click() + await expect(page.getByRole("dialog", { name: "Conversations" })).toHaveCount(0) + await expect(trigger).toBeFocused() +}) + +test("selects mobile history without horizontal page overflow", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page) + + await page.goto("/agent") + await page.getByRole("button", { name: "View conversations" }).click() + await page.getByRole("dialog", { name: "Conversations" }).getByRole("button", { name: "Example browser task" }).click() + + await expect(page.getByText("The example site is ready.")).toBeVisible() + await expect(page.getByRole("dialog", { name: "Conversations" })).toHaveCount(0) + await expect.poll(() => page.evaluate(() => ({ + body: document.body.scrollWidth <= document.body.clientWidth, + document: document.documentElement.scrollWidth <= document.documentElement.clientWidth, + }))).toEqual({ body: true, document: true }) +}) + +test("returns focus to the prompt after keyboard submission completes", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [], + turns: [{ events: [{ type: "content_delta", delta: "Keyboard complete." }, assistant("Keyboard complete.")] }], + }) + + await page.goto("/agent") + const prompt = page.getByPlaceholder("Ask a question") + await prompt.fill("Submit from keyboard") + await prompt.press("Enter") + + await expect(page.getByText("Keyboard complete.", { exact: true })).toBeVisible() + await expect(page.locator('[aria-live="polite"]').filter({ hasText: /^Latest assistant message available\.$/ })).toHaveCount(1) + await expect(page.locator('[aria-live]').filter({ hasText: /Latest user message available/ })).toHaveCount(0) + await expect(prompt).toBeEnabled() + await expect(prompt).toBeFocused() +}) + +test("exposes chat states and controls by accessible role and unique name", async ({ page }) => { + const now = new Date().toISOString() + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [{ + id: "accessible-chat", + title: "Accessible chat", + created_at: now, + updated_at: now, + messages: [ + { id: "user-one", role: "user", content: "First question", created_at: now }, + { id: "user-two", role: "user", content: "Second question", created_at: now }, + ], + }], + turns: [ + { hold: true, events: [assistant("", [bashCall("accessible-command", "printf accessible")])] }, + { error: { status: 500, message: "Accessible failure" } }, + ], + }) + + await page.goto("/agent") + await page.getByRole("button", { name: "Accessible chat" }).click() + const transcript = page.getByRole("region", { name: "Chat" }) + await expect(transcript).toBeVisible() + const bubbleLabels = await transcript.locator("[aria-label]").evaluateAll(elements => + elements.map(element => element.getAttribute("aria-label")).filter(label => label?.startsWith("You at ")), + ) + expect(new Set(bubbleLabels).size).toBe(bubbleLabels.length) + + const prompt = page.getByPlaceholder("Ask a question") + await prompt.fill("Run accessible command") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.locator('[aria-live="polite"]').filter({ hasText: "Generating response" })).toHaveCount(1) + await expect(transcript.locator(':scope > [role="status"]')).toHaveCount(0) + await expect(page.getByRole("button", { name: "Stop generating" })).toBeVisible() + chat.releaseTurn() + await expect(page.getByRole("button", { name: "Command details" })).toBeVisible() + await expect(page.getByRole("alert")).toContainText("Accessible failure") + await expect(page.locator('[aria-live]').filter({ hasText: "Accessible failure" })).toHaveCount(0) +}) + +test("scrolls the newest message into view while keeping the composer fixed", async ({ page }) => { + const now = new Date().toISOString() + const messages = Array.from({ length: 24 }, (_, index) => ({ + id: `overflow-${index}`, + role: index % 2 === 0 ? "user" as const : "assistant" as const, + content: `Overflow message ${index + 1} with enough text to occupy transcript space.`, + created_at: now, + })) + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [{ id: "overflow-chat", title: "Overflow chat", created_at: now, updated_at: now, messages }], + turns: [{ events: [{ type: "content_delta", delta: "Newest response." }, assistant("Newest response.")] }], + }) + + await page.goto("/agent") + await page.getByRole("button", { name: "Overflow chat" }).click() + const transcript = page.getByRole("region", { name: "Chat" }) + const composer = page.locator(".agent-chat-composer") + const composerBefore = await composer.boundingBox() + await transcript.evaluate(element => { element.scrollTop = 0 }) + + await page.getByPlaceholder("Ask a question").fill("Show the newest response") + await page.getByRole("button", { name: "Send message" }).click() + + await expect(page.getByText("Newest response.", { exact: true })).toBeInViewport() + await expect.poll(() => transcript.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + const composerAfter = await composer.boundingBox() + expect(composerBefore).not.toBeNull() + expect(composerAfter).not.toBeNull() + expect(composerAfter?.y).toBeCloseTo(composerBefore?.y ?? 0, 0) +}) + +test("parses fragmented unterminated NDJSON and rejects malformed final events", async ({ page }) => { + await mockAuth(page) + await page.goto("/pools") + + const result = await page.evaluate(async () => { + const api = await import("/src/sdk/chat.ts") + const originalFetch = window.fetch + const encode = (value: string) => new TextEncoder().encode(value) + const stream = (chunks: string[]) => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encode(chunk)) + controller.close() + }, + }) + + try { + window.fetch = async () => + new Response( + stream([ + '{"type":"content_delta","delta":"Hel', + 'lo"}\n{"type":"assistant","message":{"role":"assistant","content":"Complete"}}', + ]), + { status: 200 }, + ) + const deltas: string[] = [] + const complete = await api.streamTurn("conversation-1", [], delta => deltas.push(delta)) + + window.fetch = async () => + new Response( + stream([ + '{"type":"assistant","message":{"role":"assistant","content":"ok","tool_calls":{}}}', + ]), + { status: 200 }, + ) + let malformedError = "" + try { + await api.streamTurn("conversation-1", [], () => undefined) + } catch (error) { + malformedError = error instanceof Error ? error.name : String(error) + } + + return { complete, deltas, malformedError } + } finally { + window.fetch = originalFetch + } + }) + + expect(result.deltas).toEqual(["Hello"]) + expect(result.complete.content).toBe("Complete") + expect(result.malformedError).toBe("ChatApiError") +}) + +test("hides chat navigation and redirects when the feature is disabled", async ({ page }) => { + await mockAuth(page, { admin: false, chat: false }) + + await page.goto("/pools") + await expect(page.getByRole("link", { name: "Chat" })).toHaveCount(0) + + await page.goto("/agent") + await expect(page).toHaveURL(/\/pools$/) +}) + +test("disables skeleton shimmer when reduced motion is requested", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }) + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { holdList: true }) + + await page.goto("/agent") + + await expect(page.getByTestId("conversation-skeleton").first()).toBeVisible() + await expect(page.getByTestId("conversation-skeleton").first()).toHaveCSS( + "animation-name", + "none", + ) +}) + +const bashCall = (id: string, command: string, options: Record = {}) => ({ + id, + type: "function" as const, + function: { name: "bash", arguments: JSON.stringify({ command, ...options }) }, +}) + +const assistant = (content: string, toolCalls: ReturnType[] = []) => ({ + type: "assistant", + message: { role: "assistant", content, tool_calls: toolCalls }, +}) + +test("runs a browser bash tool loop and shows completed command details", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [], + turns: [ + { events: [assistant("", [bashCall("bash-1", "printf hello > note.txt; cat note.txt")])] }, + { events: [{ type: "content_delta", delta: "The file contains hello." }, assistant("The file contains hello.")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Create and read note.txt") + await page.getByRole("button", { name: "Send message" }).click() + + await expect(page.getByText("Running command")).toBeVisible() + await expect(page.getByText("Command completed")).toBeVisible() + await expect(page.getByText("The file contains hello.")).toBeVisible() + await page.getByRole("button", { name: "Command details" }).click() + await expect(page.getByText("printf hello > note.txt; cat note.txt")).toBeVisible() + await expect(page.getByText("hello", { exact: true })).toBeVisible() +}) + +test("shows failed, timed out, and truncated bash results", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [], + turns: [ + { events: [assistant("", [bashCall("failed", "false")])] }, + { events: [assistant("", [bashCall("timed", "sleep 1", { timeout_ms: 250 })])] }, + { events: [assistant("", [bashCall("truncated", "printf '%300s' x | tr ' ' x", { max_output_chars: 256 })])] }, + { events: [assistant("Done")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Exercise bash failures") + await page.getByRole("button", { name: "Send message" }).click() + + await expect(page.getByText("Command failed")).toBeVisible() + await expect(page.getByText("Command timed out")).toBeVisible() + await page.getByRole("button", { name: "Command details" }).last().click() + await expect(page.getByText("Output was truncated")).toBeVisible() +}) + +test("stops an active turn and restores the composer", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { conversations: [], turns: [{ hold: true }] }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Wait forever") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByRole("button", { name: "Stop generating" })).toBeVisible() + await page.getByRole("button", { name: "Stop generating" }).click() + await expect(page.getByRole("button", { name: "Stop generating" })).toHaveCount(0) + await expect(page.getByPlaceholder("Ask a question")).toBeEnabled() + await expect(page.getByText("Generating a response")).toHaveCount(0) +}) + +test("shows an API error and retries without duplicating the user bubble", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [], + turns: [ + { error: { status: 500, message: "Backend unavailable" } }, + { events: [{ type: "content_delta", delta: "Recovered." }, assistant("Recovered")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Try again") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByRole("alert")).toContainText("Backend unavailable") + await page.getByRole("button", { name: "Retry" }).click() + await expect(page.getByText("Recovered")).toBeVisible() + await expect(page.getByText("Try again")).toHaveCount(1) + expect(chat.turnRequests.map(request => request.messages)).toEqual([ + [{ role: "user", content: "Try again" }], + [], + ]) +}) + +test("keeps streamed content in one assistant bubble", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [], + turns: [{ events: [{ type: "content_delta", delta: "One " }, { type: "content_delta", delta: "bubble" }, assistant("One bubble")] }], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Stream") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("One bubble")).toBeVisible() + await expect(page.getByLabel(/^Assistant at /)).toHaveCount(1) +}) + +test("reconstructs stored bash command steps from tool history", async ({ page }) => { + const now = new Date().toISOString() + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [ + { + id: "stored", + title: "Stored command", + created_at: now, + updated_at: now, + messages: [ + { id: "user", role: "user", content: "Read note", created_at: now }, + { id: "call", role: "assistant", content: "", tool_calls: [bashCall("stored-call", "cat note.txt")], created_at: now }, + { id: "result", role: "tool", tool_call_id: "stored-call", content: JSON.stringify({ stdout: "hello", stderr: "", exit_code: 0, timed_out: false, truncated: false }), created_at: now }, + { id: "answer", role: "assistant", content: "The note says hello.", created_at: now }, + ], + }, + ], + }) + + await page.goto("/agent") + await page.getByRole("button", { name: "Stored command" }).click() + await expect(page.getByText("Command completed")).toBeVisible() + await page.getByText("Command details").click() + await expect(page.getByText("cat note.txt")).toBeVisible() + await expect(page.getByText("hello", { exact: true })).toBeVisible() + await expect(page.getByText("The note says hello.")).toBeVisible() +}) + +test("reconciles persisted turn history without duplicate transient entries", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { + conversations: [], + turns: [ + { events: [assistant("", [bashCall("once", "printf hello")])] }, + { events: [{ type: "content_delta", delta: "Final once." }, assistant("Final once")] }, + ], + }) + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Run once") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Final once", { exact: true })).toBeVisible() + await expect(page.getByText("Run once", { exact: true })).toHaveCount(1) + await expect(page.getByText("Command completed", { exact: true })).toHaveCount(1) + await expect(page.getByText("Final once", { exact: true })).toHaveCount(1) +}) + +test("keeps successful transient output and refreshes without rerunning the model", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [], + refreshError: { afterTurnRequests: 1, message: "Refresh unavailable" }, + turns: [{ events: [{ type: "content_delta", delta: "Completed." }, assistant("Completed")] }], + }) + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Complete then refresh") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Completed")).toBeVisible() + await expect(page.getByRole("alert")).toContainText("Refresh unavailable") + await expect(page.getByRole("button", { name: "Refresh conversation" })).toBeVisible() + await page.getByRole("button", { name: "Refresh conversation" }).click() + await expect(page.getByRole("alert")).toHaveCount(0) + expect(chat.turnRequests).toHaveLength(1) +}) + + +test("stops an open streamed response without showing generation retry", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + await mockChatApi(page, { conversations: [] }) + await page.addInitScript(() => { + const originalFetch = window.fetch.bind(window) + window.fetch = async (input, init) => { + const url = typeof input === "string" ? input : input instanceof Request ? input.url : input.toString() + if (!url.includes("/turns")) return originalFetch(input, init) + ;(window as Window & { streamHeaders?: string }).streamHeaders = "x-stream-ready" + const encoder = new TextEncoder() + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"type":"content_delta","delta":"Streaming now"}\n')) + init?.signal?.addEventListener("abort", () => controller.error(new DOMException("Aborted", "AbortError")), { once: true }) + }, + }), { status: 200, headers: { "X-Stream-Ready": "yes" } }) + } + }) + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Stop streamed response") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Streaming now")).toBeVisible() + await expect.poll(() => page.evaluate(() => (window as Window & { streamHeaders?: string }).streamHeaders)).toBe("x-stream-ready") + await page.getByRole("button", { name: "Stop generating" }).click() + await expect(page.getByRole("alert")).toHaveCount(0) + await expect(page.getByPlaceholder("Ask a question")).toBeEnabled() +}) + +test("announces conversation loading, loaded, and no selected conversation", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { holdConversation: true }) + await page.goto("/agent") + await expect(page.locator('[aria-live]').filter({ hasText: "No conversation selected" })).toBeVisible() + await page.getByRole("button", { name: "Example browser task" }).click() + await expect(page.locator('[aria-live]').filter({ hasText: "Loading conversation" })).toBeVisible() + chat.releaseConversation() + await expect(page.locator('[aria-live]').filter({ hasText: "Conversation loaded" })).toBeVisible() +}) + + +test("locks submission until post-turn refresh reconciliation completes", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [], + holdRefresh: true, + turns: [ + { events: [{ type: "content_delta", delta: "First final." }, assistant("First final.")] }, + { events: [{ type: "content_delta", delta: "Second final." }, assistant("Second final.")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("First prompt") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("First final.", { exact: true })).toBeVisible() + + const prompt = page.getByPlaceholder("Ask a question") + await prompt.fill("Second prompt") + await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled() + await page.getByRole("button", { name: "Send message" }).click({ force: true }) + expect(chat.turnRequests).toHaveLength(1) + + chat.releaseRefresh() + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled() + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Second final.", { exact: true })).toBeVisible() + expect(chat.turnRequests).toHaveLength(2) +}) + + +test("serializes held refresh recovery before allowing another turn", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [], + refreshError: { afterTurnRequests: 1, message: "Refresh unavailable" }, + holdRefresh: true, + turns: [ + { events: [{ type: "content_delta", delta: "Recovered first." }, assistant("Recovered first.")] }, + { events: [{ type: "content_delta", delta: "Second after recovery." }, assistant("Second after recovery.")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("First recovery prompt") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByRole("button", { name: "Refresh conversation" })).toBeVisible() + + await page.getByRole("button", { name: "Refresh conversation" }).click() + const prompt = page.getByPlaceholder("Ask a question") + await prompt.fill("Blocked during recovery") + await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled() + await expect(page.getByRole("button", { name: "Stop generating" })).toHaveCount(0) + await page.getByRole("button", { name: "Send message" }).click({ force: true }) + expect(chat.turnRequests).toHaveLength(1) + + chat.releaseRefresh() + await expect(page.getByRole("alert")).toHaveCount(0) + await expect(page.getByText("Recovered first.", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled() + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Second after recovery.", { exact: true })).toBeVisible() + expect(chat.turnRequests).toHaveLength(2) +}) + + +test("locks conversation selection until an active turn reconciles", async ({ page }) => { + const now = new Date().toISOString() + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [ + { id: "conversation-a", title: "Conversation A", created_at: now, updated_at: now, messages: [{ id: "a-user", role: "user", content: "A history", created_at: now }] }, + { id: "conversation-b", title: "Conversation B", created_at: now, updated_at: now, messages: [{ id: "b-user", role: "user", content: "B only message", created_at: now }] }, + ], + turns: [{ hold: true, events: [{ type: "content_delta", delta: "A final." }, assistant("A final.")] }], + }) + + await page.goto("/agent") + await page.getByRole("button", { name: "Conversation A" }).click() + await expect(page.getByText("A history")).toBeVisible() + await page.getByPlaceholder("Ask a question").fill("Run in A") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByRole("button", { name: "Conversation B" })).toBeDisabled() + await page.getByRole("button", { name: "Conversation B" }).click({ force: true }) + await expect(page.getByText("Run in A", { exact: true })).toBeVisible() + await expect(page.getByText("B only message")).toHaveCount(0) + + chat.releaseTurn() + await expect(page.getByText("A final.", { exact: true })).toBeVisible() + await expect(page.getByRole("button", { name: "Conversation B" })).toBeEnabled() + await page.getByRole("button", { name: "Conversation B" }).click() + await expect(page.getByText("B only message")).toBeVisible() + await expect(page.getByText("A final.", { exact: true })).toHaveCount(0) +}) + + +test("retries an initial conversation creation without stranding the lifecycle", async ({ page }) => { + await mockAuth(page, { admin: false, chat: true }) + const chat = await mockChatApi(page, { + conversations: [], + createError: { message: "Conversation creation failed" }, + turns: [ + { events: [{ type: "content_delta", delta: "Created after retry." }, assistant("Created after retry.")] }, + { events: [{ type: "content_delta", delta: "Follow-up works." }, assistant("Follow-up works.")] }, + ], + }) + + await page.goto("/agent") + await page.getByPlaceholder("Ask a question").fill("Create this conversation") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByRole("alert")).toContainText("Conversation creation failed") + await expect(page.getByRole("button", { name: "Retry" })).toBeVisible() + await expect(page.getByPlaceholder("Ask a question")).toBeEnabled() + await expect(page.getByLabel(/^You at /).getByText("Create this conversation", { exact: true })).toHaveCount(0) + expect(chat.createRequests).toBe(1) + expect(chat.turnRequests).toHaveLength(0) + + await page.getByRole("button", { name: "Retry" }).click() + await expect(page.getByText("Created after retry.", { exact: true })).toBeVisible() + await expect(page.getByLabel(/^You at /).getByText("Create this conversation", { exact: true })).toHaveCount(1) + expect(chat.createRequests).toBe(2) + expect(chat.turnRequests).toHaveLength(1) + expect(chat.turnRequests[0].messages).toEqual([{ role: "user", content: "Create this conversation" }]) + + await page.getByPlaceholder("Ask a question").fill("Normal follow-up") + await page.getByRole("button", { name: "Send message" }).click() + await expect(page.getByText("Follow-up works.", { exact: true })).toBeVisible() + expect(chat.turnRequests).toHaveLength(2) +}) diff --git a/libs/fleet/e2e/fixtures/mock-api.ts b/libs/fleet/e2e/fixtures/mock-api.ts index fc2d8a1c88..b786c368dd 100644 --- a/libs/fleet/e2e/fixtures/mock-api.ts +++ b/libs/fleet/e2e/fixtures/mock-api.ts @@ -51,7 +51,27 @@ const FAKE_TOKEN = * with a stub class that resolves init() immediately as authenticated. * This avoids the real keycloak-js redirecting to the Keycloak login page. */ -export async function mockAuth(page: Page): Promise { +export interface MockFeatureFlags { + admin?: boolean + billing?: boolean + chat?: boolean +} + +export interface MockAuthOptions { + holdConfig?: boolean +} + +export interface MockAuthControl { + releaseConfig(): void +} + +export async function mockAuth( + page: Page, + flags: MockFeatureFlags = {}, + options: MockAuthOptions = {}, +): Promise { + const config = deferred() + if (!options.holdConfig) config.resolve() // Intercept the Vite pre-bundled keycloak-js module. Vite serves // node_modules deps from /.vite/deps/ or /node_modules/.vite/deps/. // Replace the entire module with a stub Keycloak class. @@ -100,10 +120,24 @@ export async function mockAuth(page: Page): Promise { }) }) + await page.route("**/api/config", async route => { + await config.promise + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + admin: flags.admin ?? false, + billing: flags.billing ?? false, + chat: flags.chat ?? false, + }), + }) + }) + // Intercept any stray Keycloak endpoint requests await page.route("**/realms/cyclops-cs/**", (route) => route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), ) + + return { releaseConfig: config.resolve } } // --------------------------------------------------------------------------- @@ -498,3 +532,235 @@ export async function mockClaimsApi(page: Page): Promise { }, ) } + + +// --------------------------------------------------------------------------- +// Chat API mocking +// --------------------------------------------------------------------------- + +export interface MockChatMessage { + id: string + role: "user" | "assistant" | "tool" + content: string + created_at: string + tool_call_id?: string + tool_calls?: Array<{ + id: string + type: "function" + function: { name: string; arguments: string } + }> +} + +export interface MockChatConversation { + id: string + title: string + created_at: string + updated_at: string + messages: MockChatMessage[] +} + +export interface MockChatApiOptions { + conversations?: MockChatConversation[] + holdList?: boolean + holdConversation?: boolean + turns?: MockChatTurn[] + refreshError?: { afterTurnRequests: number; message: string } + holdRefresh?: boolean + createError?: { message: string } +} + +export interface MockChatApiControl { + authorizationHeaders: string[] + createRequests: number + turnRequests: Array<{ conversationId: string; messages: Array> }> + releaseList(): void + releaseConversation(): void + releaseRefresh(): void + releaseTurn(): void +} + +export interface MockChatTurn { + events?: Array> + error?: { status: number; message: string } + hold?: boolean +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {} + const promise = new Promise(complete => { + resolve = complete + }) + return { promise, resolve } +} + +function defaultChatConversations(): MockChatConversation[] { + const now = new Date().toISOString() + return [ + { + id: "conversation-1", + title: "Example browser task", + created_at: now, + updated_at: now, + messages: [ + { + id: "message-1", + role: "user", + content: "Open the example site.", + created_at: now, + }, + { + id: "message-2", + role: "assistant", + content: "The example site is ready.", + created_at: now, + }, + ], + }, + ] +} + +export async function mockChatApi( + page: Page, + options: MockChatApiOptions = {}, +): Promise { + const conversations = options.conversations ?? defaultChatConversations() + const authorizationHeaders: string[] = [] + const createRequests = { count: 0 } + const turnRequests: Array<{ conversationId: string; messages: Array> }> = [] + const list = deferred() + const conversation = deferred() + const refresh = deferred() + const turnGate = deferred() + const turns = [...(options.turns ?? [])] + let refreshFailuresRemaining = options.refreshError ? 1 : 0 + let createFailuresRemaining = options.createError ? 1 : 0 + + if (!options.holdList) list.resolve() + if (!options.holdConversation) conversation.resolve() + if (!options.holdRefresh) refresh.resolve() + if (!turns.some(item => item.hold)) turnGate.resolve() + + await page.route("**/api/chat/conversations", async route => { + if (route.request().method() === "GET") { + authorizationHeaders.push(route.request().headers().authorization ?? "") + if (options.refreshError && turnRequests.length >= options.refreshError.afterTurnRequests && refreshFailuresRemaining > 0) { + refreshFailuresRemaining -= 1 + await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: options.refreshError.message }) }) + return + } + await list.promise + if (options.holdRefresh && turnRequests.length > 0) await refresh.promise + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify( + conversations.map(({ messages: _messages, ...summary }) => summary), + ), + }) + return + } + + if (route.request().method() === "POST") { + createRequests.count += 1 + if (createFailuresRemaining > 0) { + createFailuresRemaining -= 1 + await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: options.createError!.message }) }) + return + } + const now = new Date().toISOString() + const created: MockChatConversation = { + id: `conversation-${conversations.length + 1}`, + title: "New conversation", + created_at: now, + updated_at: now, + messages: [], + } + conversations.unshift(created) + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify(created), + }) + return + } + + await route.continue() + }) + + await page.route("**/api/chat/conversations/*", async route => { + if (route.request().method() !== "GET") { + await route.continue() + return + } + + if (options.refreshError && turnRequests.length >= options.refreshError.afterTurnRequests && refreshFailuresRemaining > 0) { + refreshFailuresRemaining -= 1 + await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: options.refreshError.message }) }) + return + } + await conversation.promise + if (options.holdRefresh && turnRequests.length > 0) await refresh.promise + const id = new URL(route.request().url()).pathname.split("/").pop() + const selected = conversations.find(item => item.id === id) + if (!selected) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "conversation not found" }), + }) + return + } + await route.fulfill({ contentType: "application/json", body: JSON.stringify(selected) }) + }) + + await page.route("**/api/chat/conversations/*/turns", async route => { + if (route.request().method() !== "POST") { + await route.continue() + return + } + + const url = new URL(route.request().url()) + const conversationId = url.pathname.split("/").slice(-2, -1)[0] + const body = route.request().postDataJSON() as { messages?: Array> } + turnRequests.push({ conversationId, messages: body.messages ?? [] }) + const now = new Date().toISOString() + const selected = conversations.find(item => item.id === conversationId) + if (selected) { + for (const message of body.messages ?? []) { + selected.messages.push({ id: `message-${selected.messages.length + 1}`, role: message.role as MockChatMessage["role"], content: String(message.content ?? ""), created_at: now, ...(typeof message.tool_call_id === "string" ? { tool_call_id: message.tool_call_id } : {}) }) + } + selected.updated_at = now + } + const turn = turns.shift() + if (!turn) { + await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "No mock turn" }) }) + return + } + if (turn.hold) await turnGate.promise + if (turn.error) { + await route.fulfill({ + status: turn.error.status, + contentType: "application/json", + body: JSON.stringify({ error: turn.error.message }), + }) + return + } + if (selected) { + const final = [...(turn.events ?? [])].reverse().find(event => event.type === "assistant")?.message as Record | undefined + if (final) selected.messages.push({ id: `message-${selected.messages.length + 1}`, role: "assistant", content: String(final.content ?? ""), created_at: now, ...(Array.isArray(final.tool_calls) ? { tool_calls: final.tool_calls as MockChatMessage["tool_calls"] } : {}) }) + } + await route.fulfill({ + contentType: "application/x-ndjson", + body: (turn.events ?? []).map(event => JSON.stringify(event) + "\n").join(""), + }) + }) + + return { + authorizationHeaders, + get createRequests() { return createRequests.count }, + turnRequests, + releaseList: list.resolve, + releaseConversation: conversation.resolve, + releaseRefresh: refresh.resolve, + releaseTurn: turnGate.resolve, + } +} diff --git a/libs/fleet/nginx.conf b/libs/fleet/nginx.conf index f19167db18..bcf77dcbb9 100644 --- a/libs/fleet/nginx.conf +++ b/libs/fleet/nginx.conf @@ -148,7 +148,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/api/(keys|user-keys|github-trust-policies|gateway|k8s|orch|swagger|batch|label|namespaces|config|billing|state)(/|$) { + location ~ ^/api/(keys|user-keys|github-trust-policies|gateway|k8s|orch|swagger|batch|label|namespaces|config|chat|billing|state)(/|$) { proxy_pass ${CYCLOPS_CS_BACKEND}; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/libs/fleet/package.json b/libs/fleet/package.json index 6de3dbdf4a..5847dbbb53 100644 --- a/libs/fleet/package.json +++ b/libs/fleet/package.json @@ -9,12 +9,13 @@ "build": "npm run sdk:browser && npm run build:app", "build:app": "tsc -b && vite build", "preview": "vite preview", - "typecheck": "npm run sdk:browser && tsc -b --noEmit", + "typecheck": "npm run sdk:browser && tsc -b --noEmit && tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --allowImportingTsExtensions --skipLibCheck agent.test.ts src/browser-agent.ts", "lint": "biome check", "lint:fix": "biome check --write", "test:e2e": "npm run sdk:browser && playwright test", "test:e2e:ui": "npm run sdk:browser && playwright test --ui", - "test:sdk-models": "rm -rf target/sdk-model-tests && tsc e2e/sdk-models.unit.ts src/sdk/status.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir target/sdk-model-tests --skipLibCheck && node target/sdk-model-tests/e2e/sdk-models.unit.js" + "test:sdk-models": "rm -rf target/sdk-model-tests && tsc e2e/sdk-models.unit.ts src/sdk/status.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir target/sdk-model-tests --skipLibCheck && node target/sdk-model-tests/e2e/sdk-models.unit.js", + "test:agent": "node --test --experimental-strip-types agent.test.ts" }, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.93", @@ -26,7 +27,9 @@ "keycloak-js": "^25.0.6", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "@cloudscape-design/chat-components": "^1.0.157", + "just-bash": "^3.2.0" }, "devDependencies": { "@biomejs/biome": "^2.4.15", @@ -35,6 +38,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", - "vite": "^5.4.11" + "vite": "^5.4.11", + "@types/node": "^22.19.17" } } diff --git a/libs/fleet/pnpm-lock.yaml b/libs/fleet/pnpm-lock.yaml index 596e36af5e..ebfd1fb98c 100644 --- a/libs/fleet/pnpm-lock.yaml +++ b/libs/fleet/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: dependencies: + '@cloudscape-design/chat-components': + specifier: ^1.0.157 + version: 1.0.159(@cloudscape-design/components@3.0.1292(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@cloudscape-design/collection-hooks': specifier: ^1.0.93 version: 1.0.93(react@18.3.1) @@ -31,6 +34,9 @@ importers: ansi-to-html: specifier: ^0.7.2 version: 0.7.2 + just-bash: + specifier: ^3.2.0 + version: 3.2.0 keycloak-js: specifier: ^25.0.6 version: 25.0.6 @@ -50,6 +56,9 @@ importers: '@playwright/test': specifier: ^1.60.0 version: 1.60.0 + '@types/node': + specifier: ^22.19.17 + version: 22.20.1 '@types/react': specifier: ^18.3.12 version: 18.3.28 @@ -58,13 +67,13 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@5.4.21) + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)) typescript: specifier: ^5.7.2 version: 5.9.3 vite: specifier: ^5.4.11 - version: 5.4.21 + version: 5.4.21(@types/node@22.20.1) packages: @@ -208,6 +217,15 @@ packages: cpu: [x64] os: [win32] + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + + '@cloudscape-design/chat-components@1.0.159': + resolution: {integrity: sha512-c3VQhhe4tm0I0lcPVi/8IGVdlcAPI/Wcwi0byv/7QmbsjLlPPIHlAm8uzSt+BclrQLH0Q8FwTIM/nVCKWmKVyw==} + peerDependencies: + '@cloudscape-design/components': ^3 + react: '>=18.2.0' + '@cloudscape-design/collection-hooks@1.0.93': resolution: {integrity: sha512-77khCT0cwxxEddRxfsvuZNlrlu/Qk2CNPm+tMsMTXoL1/n4mrDKQh/AA0sbnvCga7m4g9xS3kW27RfN1H9eu1Q==} peerDependencies: @@ -407,6 +425,21 @@ packages: '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -426,6 +459,16 @@ packages: '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + + '@mongodb-js/zstd@7.0.0': + resolution: {integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==} + engines: {node: '>= 20.19.0'} + + '@nodable/entities@3.0.0': + resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@novnc/novnc@1.7.0': resolution: {integrity: sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==} @@ -566,6 +609,13 @@ packages: cpu: [x64] os: [win32] + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -581,6 +631,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -609,23 +662,50 @@ packages: engines: {node: '>=8.0.0'} hasBin: true + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.28: resolution: {integrity: sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA==} engines: {node: '>=6.0.0'} hasBin: true + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -665,12 +745,31 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} electron-to-chromium@1.5.353: resolution: {integrity: sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} @@ -683,9 +782,27 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-xml-builder@1.3.0: + resolution: {integrity: sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==} + + fast-xml-parser@5.10.1: + resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} + hasBin: true + fastparse@1.1.2: resolution: {integrity: sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==} + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -700,9 +817,28 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + intl-messageformat@10.7.18: resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==} + is-unsafe@2.0.0: + resolution: {integrity: sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==} + js-sha256@0.11.1: resolution: {integrity: sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==} @@ -719,6 +855,11 @@ packages: engines: {node: '>=6'} hasBin: true + just-bash@3.2.0: + resolution: {integrity: sha512-hRTLLWBXCKuosjaNFJR7uPYBza+T2vjG3NdPBz4wxlctlnxwrbLjtLQf6RtSKmy/jzNJ6/URCtvxrXjy6yFGeQ==} + engines: {node: '>=20.18.1'} + hasBin: true + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -733,10 +874,28 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mnth@2.0.0: resolution: {integrity: sha512-3ZH4UWBGpAwCKdfjynLQpUDVZWMe6vRHwarIpMdGLUp89CVR9hjzgyWERtMyqx+fPEqQ/PsAxFwvwPxLFxW40A==} engines: {node: '>=12.13.0'} + modern-tar@0.7.7: + resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + engines: {node: '>=18.0.0'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -745,6 +904,26 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-addon-api@8.9.1: + resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-liblzma@2.2.0: + resolution: {integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==} + engines: {node: '>=16.0.0'} + hasBin: true + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -752,6 +931,16 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + papaparse@5.5.4: + resolution: {integrity: sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==} + + path-expression-matcher@1.6.2: + resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} + engines: {node: '>=14.0.0'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -769,9 +958,32 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + re2js@1.3.3: + resolution: {integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -810,36 +1022,114 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + rollup@4.60.3: resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} @@ -877,9 +1167,21 @@ packages: weekstart@2.0.0: resolution: {integrity: sha512-HjYc14IQUwDcnGICuc8tVtqAd6EFpoAQMqgrqcNtWWZB+F1b7iTq44GzwM1qvnH4upFgbhJsaNHuK93NOFheSg==} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xml-naming@0.3.0: + resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} + engines: {node: '>=16.0.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: '@babel/code-frame@7.29.0': @@ -1031,6 +1333,16 @@ snapshots: '@biomejs/cli-win32-x64@2.4.15': optional: true + '@borewit/text-codec@0.2.2': {} + + '@cloudscape-design/chat-components@1.0.159(@cloudscape-design/components@3.0.1292(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@cloudscape-design/component-toolkit': 1.0.0-beta.159(react@18.3.1) + '@cloudscape-design/components': 3.0.1292(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@cloudscape-design/test-utils-core': 1.0.81 + clsx: 1.2.1 + react: 18.3.1 + '@cloudscape-design/collection-hooks@1.0.93(react@18.3.1)': dependencies: '@cloudscape-design/component-toolkit': 1.0.0-beta.159(react@18.3.1) @@ -1197,6 +1509,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1218,6 +1548,16 @@ snapshots: '@material/material-color-utilities@0.3.0': {} + '@mixmark-io/domino@2.2.0': {} + + '@mongodb-js/zstd@7.0.0': + dependencies: + node-addon-api: 8.9.1 + prebuild-install: 7.1.3 + optional: true + + '@nodable/entities@3.0.0': {} + '@novnc/novnc@1.7.0(patch_hash=c119c00d14c51f940f1ebed4fe41ad2fe1358c63e1095baa39d11d696b66dbff)': {} '@playwright/test@1.60.0': @@ -1303,6 +1643,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.3': optional: true + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.3 @@ -1326,6 +1675,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.28)': @@ -1339,7 +1692,7 @@ snapshots: '@ubjs/core@0.31.0-3': {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21)': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -1347,7 +1700,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21 + vite: 5.4.21(@types/node@22.20.1) transitivePeerDependencies: - supports-color @@ -1357,8 +1710,26 @@ snapshots: dependencies: entities: 2.2.0 + anynum@1.0.1: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: + optional: true + baseline-browser-mapping@2.10.28: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.28 @@ -1367,10 +1738,21 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + caniuse-lite@1.0.30001792: {} + chownr@1.1.4: + optional: true + clsx@1.2.1: {} + commander@6.2.1: {} + convert-source-map@2.0.0: {} css-selector-tokenizer@0.8.0: @@ -1400,6 +1782,19 @@ snapshots: decimal.js@10.6.0: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + + deep-extend@0.6.0: + optional: true + + detect-libc@2.1.2: + optional: true + + diff@8.0.4: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -1407,6 +1802,11 @@ snapshots: electron-to-chromium@1.5.353: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + entities@2.2.0: {} esbuild@0.21.5: @@ -1437,8 +1837,37 @@ snapshots: escalade@3.2.0: {} + expand-template@2.0.3: + optional: true + + fast-xml-builder@1.3.0: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.10.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.0 + is-unsafe: 2.0.0 + path-expression-matcher: 1.6.2 + strnum: 2.4.1 + xml-naming: 0.3.0 + fastparse@1.1.2: {} + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + fs-constants@1.0.0: + optional: true + fsevents@2.3.2: optional: true @@ -1447,6 +1876,19 @@ snapshots: gensync@1.0.0-beta.2: {} + github-from-package@0.0.0: + optional: true + + ieee754@1.2.1: {} + + inherits@2.0.4: + optional: true + + ini@1.3.8: + optional: true + + ini@6.0.0: {} + intl-messageformat@10.7.18: dependencies: '@formatjs/ecma402-abstract': 2.3.6 @@ -1454,6 +1896,8 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.4 tslib: 2.8.1 + is-unsafe@2.0.0: {} + js-sha256@0.11.1: {} js-tokens@4.0.0: {} @@ -1462,6 +1906,30 @@ snapshots: json5@2.2.3: {} + just-bash@3.2.0: + dependencies: + diff: 8.0.4 + fast-xml-parser: 5.10.1 + file-type: 21.3.4 + ini: 6.0.0 + minimatch: 10.2.6 + modern-tar: 0.7.7 + papaparse: 5.5.4 + quickjs-emscripten: 0.32.0 + re2js: 1.3.3 + seek-bzip: 2.0.0 + smol-toml: 1.8.0 + sprintf-js: 1.1.3 + sql.js: 1.14.1 + turndown: 7.2.4 + undici: 7.29.0 + yaml: 2.9.0 + optionalDependencies: + '@mongodb-js/zstd': 7.0.0 + node-liblzma: 2.2.0 + transitivePeerDependencies: + - supports-color + jwt-decode@4.0.0: {} keycloak-js@25.0.6: @@ -1477,18 +1945,62 @@ snapshots: dependencies: yallist: 3.1.1 + mimic-response@3.1.0: + optional: true + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimist@1.2.8: + optional: true + + mkdirp-classic@0.5.3: + optional: true + mnth@2.0.0: dependencies: '@babel/runtime': 7.29.2 + modern-tar@0.7.7: {} + ms@2.1.3: {} nanoid@3.3.12: {} + napi-build-utils@2.0.0: + optional: true + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + optional: true + + node-addon-api@8.9.1: + optional: true + + node-gyp-build@4.8.4: + optional: true + + node-liblzma@2.2.0: + dependencies: + node-addon-api: 8.9.1 + node-gyp-build: 4.8.4 + optional: true + node-releases@2.0.38: {} object-assign@4.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optional: true + + papaparse@5.5.4: {} + + path-expression-matcher@1.6.2: {} + picocolors@1.1.1: {} playwright-core@1.60.0: {} @@ -1505,12 +2017,56 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + + re2js@1.3.3: {} + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -1548,6 +2104,13 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + optional: true + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 @@ -1579,34 +2142,125 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.3 fsevents: 2.3.3 + safe-buffer@5.2.1: + optional: true + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + semver@6.3.1: {} + semver@7.8.5: + optional: true + + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} + sprintf-js@1.1.3: {} + + sql.js@1.14.1: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + strip-json-comments@2.0.1: + optional: true + + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + + undici-types@6.21.0: {} + + undici@7.29.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 - vite@5.4.21: + util-deprecate@1.0.2: + optional: true + + vite@5.4.21(@types/node@22.20.1): dependencies: esbuild: 0.21.5 postcss: 8.5.14 rollup: 4.60.3 optionalDependencies: + '@types/node': 22.20.1 fsevents: 2.3.3 weekstart@1.1.0: {} weekstart@2.0.0: {} + wrappy@1.0.2: + optional: true + + xml-naming@0.3.0: {} + yallist@3.1.1: {} + + yaml@2.9.0: {} diff --git a/libs/fleet/src/App.tsx b/libs/fleet/src/App.tsx index 0ca1904df5..5bee18e111 100644 --- a/libs/fleet/src/App.tsx +++ b/libs/fleet/src/App.tsx @@ -21,7 +21,8 @@ import { PoolDetail } from "./pages/PoolDetail" import { PoolNew } from "./pages/PoolNew" import { UserApiKeys } from "./pages/UserApiKeys" import { Settings } from "./pages/Settings" -import { FeatureFlagProvider } from "./components/FeatureFlagContext" +import { AgentChat } from "./pages/AgentChat" +import { FeatureFlagProvider, useFeatureFlags } from "./components/FeatureFlagContext" import { FlashContext, type FlashMsg } from "./components/FlashContext" import { logout, userInfo } from "./auth/keycloak" @@ -80,6 +81,7 @@ function Shell() { const { stale } = useStaleCheck() const [staleDismissed, setStaleDismissed] = useState(false) const user = userInfo() + const { chat } = useFeatureFlags() const pushFlash = useCallback((msg: FlashMsg) => { const id = crypto.randomUUID() const dismiss = () => @@ -140,6 +142,7 @@ function Shell() { }} items={[ { type: "link", text: "Pools", href: "#/pools" }, + ...(chat ? [{ type: "link" as const, text: "Chat", href: "#/agent" }] : []), { type: "link", text: "User API keys", href: "#/user-keys" }, { type: "link", text: "Settings", href: "#/settings" }, ]} @@ -194,6 +197,7 @@ export function App() { } /> } /> } /> + } /> } /> } + +function ChatRoute() { + const { chat, resolved } = useFeatureFlags() + if (!resolved) return null + return chat ? : +} diff --git a/libs/fleet/src/browser-agent.ts b/libs/fleet/src/browser-agent.ts new file mode 100644 index 0000000000..a6b0b553a0 --- /dev/null +++ b/libs/fleet/src/browser-agent.ts @@ -0,0 +1,218 @@ +import { Bash } from "just-bash/browser" + +export const BASH_TIMEOUT = { min: 250, default: 10_000, max: 60_000 } as const +export const BASH_OUTPUT = { min: 256, default: 20_000, max: 100_000 } as const + +export interface BashToolArguments { + command: string + timeout_ms?: number + max_output_chars?: number +} + +export interface NormalizedBashArguments { + command: string + timeout_ms: number + max_output_chars: number +} + +export interface BashToolResult { + stdout: string + stderr: string + exit_code: number + timed_out: boolean + truncated: boolean +} + +export interface ToolCall { + id: string + type: "function" + function: { name: string; arguments: string } +} + +export interface ClientMessage { + role: "user" | "tool" + content: string + tool_call_id?: string +} + +export interface AssistantMessage { + role: "assistant" + content: string + tool_calls: ToolCall[] +} + +export type TurnClient = ( + conversationID: string, + messages: ClientMessage[], + signal?: AbortSignal, + onDelta?: (delta: string) => void, +) => Promise + +export type AgentEvent = + | { type: "generation_start" } + | { type: "assistant_delta"; delta: string } + | { type: "tool_start"; toolCall: ToolCall; arguments: NormalizedBashArguments } + | { type: "tool_result"; toolCall: ToolCall; arguments: NormalizedBashArguments; result: BashToolResult } + | { type: "complete"; message: AssistantMessage } + +export function normalizeBashArguments(input: BashToolArguments): NormalizedBashArguments { + if (!input || typeof input.command !== "string" || input.command.trim() === "") { + throw new Error("bash.command must be a non-empty string") + } + const clamp = (value: number | undefined, bounds: typeof BASH_TIMEOUT | typeof BASH_OUTPUT) => + Math.min(bounds.max, Math.max(bounds.min, Number.isFinite(value) ? Math.trunc(value!) : bounds.default)) + return { + command: input.command, + timeout_ms: clamp(input.timeout_ms, BASH_TIMEOUT), + max_output_chars: clamp(input.max_output_chars, BASH_OUTPUT), + } +} + +function abortError(): DOMException { + return new DOMException("The operation was aborted", "AbortError") +} + +export function sanitizeError(error: unknown): string { + const message = error instanceof Error ? error.message : "Bash execution failed" + const sanitized = Array.from(message, character => { + const codePoint = character.codePointAt(0) ?? 0 + return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f) ? " " : character + }) + .join("") + .trim() + return sanitized || "Bash execution failed" +} + +function capOutput(stdout: string, stderr: string, maxOutputChars: number): Pick { + const truncated = stdout.length + stderr.length > maxOutputChars + const cappedStdout = stdout.slice(0, maxOutputChars) + const remaining = Math.max(0, maxOutputChars - cappedStdout.length) + return { stdout: cappedStdout, stderr: stderr.slice(0, remaining), truncated } +} + +export class BrowserBashAgent { + private readonly shells = new Map() + private readonly client: TurnClient + + constructor(client: TurnClient) { + this.client = client + } + + async executeBash(conversationID: string, input: BashToolArguments, signal?: AbortSignal): Promise { + const arguments_ = normalizeBashArguments(input) + if (signal?.aborted) { + throw abortError() + } + + const timeoutController = new AbortController() + const linkedController = new AbortController() + let timedOut = false + const abortLinked = () => linkedController.abort() + const abortOnTimeout = () => { + timedOut = true + timeoutController.abort() + linkedController.abort() + } + const timeout = setTimeout(abortOnTimeout, arguments_.timeout_ms) + signal?.addEventListener("abort", abortLinked, { once: true }) + timeoutController.signal.addEventListener("abort", abortLinked, { once: true }) + + try { + const shell = this.getShell(conversationID) + const result = await shell.exec(arguments_.command, { signal: linkedController.signal }) + if (signal?.aborted) { + throw abortError() + } + if (timedOut) { + return this.withOutputLimit({ stdout: result.stdout, stderr: result.stderr, exit_code: 124, timed_out: true }, input, arguments_) + } + return this.withOutputLimit( + { stdout: result.stdout, stderr: result.stderr, exit_code: result.exitCode, timed_out: false }, + input, + arguments_, + ) + } catch (error) { + if (signal?.aborted) { + throw abortError() + } + if (timedOut) { + return this.withOutputLimit({ stdout: "", stderr: "", exit_code: 124, timed_out: true }, input, arguments_) + } + return this.withOutputLimit({ stdout: "", stderr: sanitizeError(error), exit_code: 1, timed_out: false }, input, arguments_) + } finally { + clearTimeout(timeout) + signal?.removeEventListener("abort", abortLinked) + timeoutController.signal.removeEventListener("abort", abortLinked) + } + } + + async run( + conversationID: string, + messages: ClientMessage[], + onEvent: (event: AgentEvent) => void, + signal?: AbortSignal, + ): Promise { + let turnMessages = messages + for (;;) { + if (signal?.aborted) { + throw abortError() + } + onEvent({ type: "generation_start" }) + const message = await this.client(conversationID, turnMessages, signal, delta => { + onEvent({ type: "assistant_delta", delta }) + }) + if (signal?.aborted) { + throw abortError() + } + if (message.tool_calls.length === 0) { + onEvent({ type: "complete", message }) + return + } + + const calls = message.tool_calls.map(toolCall => { + if (toolCall.function.name !== "bash") { + throw new Error(`Unsupported tool: ${toolCall.function.name}`) + } + return { + toolCall, + arguments: normalizeBashArguments(JSON.parse(toolCall.function.arguments) as BashToolArguments), + } + }) + const results = await Promise.all( + calls.map(async ({ toolCall, arguments: arguments_ }) => { + onEvent({ type: "tool_start", toolCall, arguments: arguments_ }) + const result = await this.executeBash(conversationID, arguments_, signal) + onEvent({ type: "tool_result", toolCall, arguments: arguments_, result }) + return { toolCall, result } + }), + ) + turnMessages = results.map(({ toolCall, result }) => ({ + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify(result), + })) + } + } + + private getShell(conversationID: string): Bash { + let shell = this.shells.get(conversationID) + if (!shell) { + shell = new Bash({ executionLimitProfile: "hardened" }) + this.shells.set(conversationID, shell) + } + return shell + } + + private withOutputLimit( + result: Omit, + input: BashToolArguments, + arguments_: NormalizedBashArguments, + ): BashToolResult { + const requestedOutputLimit = input.max_output_chars + const maxOutputChars = + Number.isFinite(requestedOutputLimit) && requestedOutputLimit! > 0 + ? Math.min(BASH_OUTPUT.max, Math.trunc(requestedOutputLimit!)) + : arguments_.max_output_chars + return { ...result, ...capOutput(result.stdout, result.stderr, maxOutputChars) } + } +} diff --git a/libs/fleet/src/components/FeatureFlagContext.tsx b/libs/fleet/src/components/FeatureFlagContext.tsx index 0ae95a159c..4d9e86f973 100644 --- a/libs/fleet/src/components/FeatureFlagContext.tsx +++ b/libs/fleet/src/components/FeatureFlagContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react" import { fetchFeatureFlags, type FeatureFlags } from "../sdk/featureFlags" -const DEFAULT_FLAGS: FeatureFlags = { admin: false, billing: false } +const DEFAULT_FLAGS: FeatureFlags = { admin: false, billing: false, chat: false } interface FeatureFlagContextValue extends FeatureFlags { resolved: boolean diff --git a/libs/fleet/src/node-zlib-browser.ts b/libs/fleet/src/node-zlib-browser.ts new file mode 100644 index 0000000000..4c29e9d17e --- /dev/null +++ b/libs/fleet/src/node-zlib-browser.ts @@ -0,0 +1,14 @@ +const unsupportedMessage = "gzip commands are unsupported in the browser" + +export const constants = { + Z_BEST_COMPRESSION: 9, + Z_BEST_SPEED: 1, + Z_DEFAULT_COMPRESSION: -1, +} as const + +function unsupported(_input: Uint8Array, _options?: unknown): never { + throw new Error(unsupportedMessage) +} + +export const gzipSync = unsupported +export const gunzipSync = unsupported diff --git a/libs/fleet/src/pages/AgentChat.css b/libs/fleet/src/pages/AgentChat.css new file mode 100644 index 0000000000..422a938118 --- /dev/null +++ b/libs/fleet/src/pages/AgentChat.css @@ -0,0 +1,163 @@ +.agent-chat-page { + display: grid; + gap: 24px; + grid-template-columns: minmax(230px, 300px) minmax(0, 1fr); + height: calc(100vh - 160px); + min-height: 560px; +} + +.agent-chat-history { + border-inline-end: 1px solid var(--color-border-divider-default, #d5dbdb); + min-width: 0; + overflow-y: auto; + padding-inline-end: 20px; +} + +.agent-chat-main { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + min-width: 0; +} + +.agent-chat-transcript { + min-height: 0; + overflow-y: auto; + padding: 8px 8px 128px; +} + +.agent-chat-empty { + display: grid; + gap: 12px; + margin: 72px auto; + max-width: 520px; + text-align: center; +} + +.agent-chat-composer { + background: var(--color-background-layout-main, #fff); + border-top: 1px solid var(--color-border-divider-default, #d5dbdb); + bottom: 0; + padding: 16px 0 8px; + position: sticky; + z-index: 1; +} + +.agent-chat-timestamp { + color: var(--color-text-body-secondary, #5f6b7a); + font-size: 0.75rem; +} + +.agent-chat-skeleton { + animation: agent-chat-shimmer 1.4s ease-in-out infinite; + background: linear-gradient( + 90deg, + var(--color-background-layout-toggle-default, #eaeded) 25%, + var(--color-background-layout-toggle-hover, #f2f3f3) 50%, + var(--color-background-layout-toggle-default, #eaeded) 75% + ); + background-size: 200% 100%; + border-radius: 8px; +} + +.agent-chat-skeleton--conversation { + height: 48px; + width: 100%; +} + +.agent-chat-skeleton--message { + height: 88px; + max-width: 78%; +} + +.agent-chat-skeleton--message:nth-child(even) { + margin-inline-start: auto; +} + +.agent-chat-mobile-history { + display: none; +} + +@keyframes agent-chat-shimmer { + 0% { + background-position: 100% 0; + } + 100% { + background-position: -100% 0; + } +} + +@media (max-width: 700px) { + .agent-chat-page { + display: block; + height: calc(100vh - 120px); + min-height: 480px; + } + + .agent-chat-history { + display: none; + } + + .agent-chat-mobile-history { + display: block; + padding-bottom: 12px; + } + + .agent-chat-transcript { + padding-inline: 0; + } + + .agent-chat-empty { + margin: 48px 16px; + text-align: left; + } + + .agent-chat-skeleton--message { + max-width: 90%; + } +} + +.agent-chat-mobile-overlay { + align-items: stretch; + background: rgb(0 0 0 / 45%); + display: flex; + inset: 0; + position: fixed; + z-index: 1000; +} + +.agent-chat-mobile-backdrop { + background: transparent; + border: 0; + cursor: default; + inset: 0; + padding: 0; + position: absolute; +} + +.agent-chat-mobile-drawer { + position: relative; + z-index: 1; + background: var(--color-background-layout-main, #fff); + box-shadow: 2px 0 12px rgb(0 0 0 / 25%); + max-width: min(360px, 92vw); + min-width: min(320px, 92vw); + overflow-y: auto; +} + +@media (prefers-reduced-motion: reduce) { + .agent-chat-skeleton { + animation: none; + } +} + +.agent-chat-command { + border-inline-start: 3px solid var(--color-border-status-info, #3184c2); + margin-inline: 16px; + padding: 8px 12px; +} + +.agent-chat-prompt { + flex: 1; + min-width: 0; +} diff --git a/libs/fleet/src/pages/AgentChat.tsx b/libs/fleet/src/pages/AgentChat.tsx new file mode 100644 index 0000000000..39d1edefab --- /dev/null +++ b/libs/fleet/src/pages/AgentChat.tsx @@ -0,0 +1,558 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { createPortal } from "react-dom" +import Avatar from "@cloudscape-design/chat-components/avatar" +import ChatBubble from "@cloudscape-design/chat-components/chat-bubble" +import Alert from "@cloudscape-design/components/alert" +import Box from "@cloudscape-design/components/box" +import Button, { type ButtonProps } from "@cloudscape-design/components/button" +import Drawer from "@cloudscape-design/components/drawer" +import ExpandableSection from "@cloudscape-design/components/expandable-section" +import Header from "@cloudscape-design/components/header" +import List from "@cloudscape-design/components/list" +import LiveRegion from "@cloudscape-design/components/live-region" +import PromptInput from "@cloudscape-design/components/prompt-input" +import SpaceBetween from "@cloudscape-design/components/space-between" +import StatusIndicator from "@cloudscape-design/components/status-indicator" +import { + createConversation, + getConversation, + listConversations, + streamTurn, + type ChatMessage, + type ChatToolCall, + type Conversation, + type ConversationSummary, +} from "../sdk/chat" +import { + BrowserBashAgent, + type BashToolResult, + type ClientMessage, + type NormalizedBashArguments, + type ToolCall, +} from "../browser-agent" +import "./AgentChat.css" + +export interface ConversationGroup { + label: string + conversations: ConversationSummary[] +} + +interface CommandStep { + id: string + arguments: NormalizedBashArguments + result?: BashToolResult + running?: boolean +} + +type TranscriptItem = { type: "message"; message: ChatMessage } | { type: "command"; command: CommandStep } + +export function groupConversations(now: Date, summaries: ConversationSummary[]): ConversationGroup[] { + const startOfToday = new Date(now) + startOfToday.setHours(0, 0, 0, 0) + const groups = new Map() + + for (const summary of summaries) { + const updatedAt = new Date(summary.updated_at) + const startOfDate = new Date(updatedAt) + startOfDate.setHours(0, 0, 0, 0) + const daysAgo = Math.round((startOfToday.getTime() - startOfDate.getTime()) / 86_400_000) + const label = + daysAgo === 0 + ? "Today" + : daysAgo === 1 + ? "Yesterday" + : daysAgo >= 2 && daysAgo <= 7 + ? "Past 7 days" + : updatedAt.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" }) + const group = groups.get(label) ?? [] + group.push(summary) + groups.set(label, group) + } + + return [...groups].map(([label, conversations]) => ({ label, conversations })) +} + +const MOBILE_HISTORY_QUERY = "(max-width: 700px)" +const GENERATION_LOADING_DELAY_MS = 300 + +function useMobileHistory(): boolean { + const [mobile, setMobile] = useState(() => window.matchMedia(MOBILE_HISTORY_QUERY).matches) + + useEffect(() => { + const mediaQuery = window.matchMedia(MOBILE_HISTORY_QUERY) + const update = () => setMobile(mediaQuery.matches) + update() + mediaQuery.addEventListener("change", update) + return () => mediaQuery.removeEventListener("change", update) + }, []) + + return mobile +} + +function Skeleton({ kind }: { kind: "conversation" | "message" }) { + return + } + > +
{children}
+ + ) +} diff --git a/libs/fleet/src/components/PageState.tsx b/libs/fleet/src/components/PageState.tsx new file mode 100644 index 0000000000..0f80182aa5 --- /dev/null +++ b/libs/fleet/src/components/PageState.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react" +import Box from "@cloudscape-design/components/box" +import SpaceBetween from "@cloudscape-design/components/space-between" + +interface PageStateProps { + title: ReactNode + children?: ReactNode + action?: ReactNode + role?: "status" | "alert" +} + +function PageState({ title, children, action, role = "status" }: PageStateProps) { + return ( + +
+ + {title} + {children ? ( + + {children} + + ) : null} + {action} + +
+
+ ) +} + +export type PageEmptyProps = Omit + +export function PageEmpty(props: PageEmptyProps) { + return +} + +export type PageErrorProps = Omit + +export function PageError(props: PageErrorProps) { + return +} diff --git a/libs/fleet/src/components/PoolStatus.tsx b/libs/fleet/src/components/PoolStatus.tsx index b5e15b1a80..8d73f62c63 100644 --- a/libs/fleet/src/components/PoolStatus.tsx +++ b/libs/fleet/src/components/PoolStatus.tsx @@ -1,5 +1,5 @@ import StatusIndicator, { - StatusIndicatorProps, + type StatusIndicatorProps, } from "@cloudscape-design/components/status-indicator" import type { PoolStatus } from "../sdk/status" @@ -10,10 +10,20 @@ const POOL_STATUS_TYPE: Record = unknown: "pending", } -export function PoolStatusPill({ status }: { status: PoolStatus }) { +export function PoolStatusPill({ + status, + compact = false, +}: { + status: PoolStatus + compact?: boolean +}) { return ( - - {status.label} - + + + + {status.label} + + + ) } diff --git a/libs/fleet/src/error-message.ts b/libs/fleet/src/error-message.ts new file mode 100644 index 0000000000..ad171e4e3d --- /dev/null +++ b/libs/fleet/src/error-message.ts @@ -0,0 +1,10 @@ +export function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error) return error + if (error == null) return "Unknown error" + try { + return String(error) + } catch { + return "Unknown error" + } +} diff --git a/libs/fleet/src/local-visual-preview.ts b/libs/fleet/src/local-visual-preview.ts new file mode 100644 index 0000000000..8594c1e5dc --- /dev/null +++ b/libs/fleet/src/local-visual-preview.ts @@ -0,0 +1,209 @@ +import type { PoolData, PoolSummary } from "./sdk/models" +import type { + ChatMessage, + Conversation, + ConversationSummary, +} from "./sdk/chat" + +export function isLocalVisualPreview(): boolean { + const localPreview = + import.meta.env.DEV && + import.meta.env.VITE_CUA_LOCAL_VISUAL_PREVIEW === "true" + const pullRequestPreview = + import.meta.env.VITE_CUA_REVIEW_VISUAL_PREVIEW === "true" && + /^cyclops-cs-pr-\d+\.tail204509\.ts\.net$/.test( + window.location.hostname, + ) + if (!localPreview && !pullRequestPreview) return false + return new URLSearchParams(window.location.search).has("cua-visual-preview") +} + +export function localVisualPreviewPath(path: string): string { + if (!isLocalVisualPreview()) return path + return `${path}${path.includes("?") ? "&" : "?"}cua-visual-preview` +} + +export const localVisualPreviewPools: PoolSummary[] = [ + { name: "prod-web-fleet", namespace: "preview", replicas: 120, availableCount: 118, phase: "Ready" }, + { name: "prod-api-fleet", namespace: "preview", replicas: 80, availableCount: 79, phase: "Ready" }, + { name: "staging-web-fleet", namespace: "preview", replicas: 40, availableCount: 38, phase: "Ready" }, + { name: "staging-api-fleet", namespace: "preview", replicas: 30, availableCount: 28, phase: "Scaling" }, + { name: "dev-tools-fleet", namespace: "preview", replicas: 20, availableCount: 20, phase: "Ready" }, + { name: "eval-runner-fleet", namespace: "preview", replicas: 16, availableCount: 14, phase: "Scaling" }, + { name: "batch-jobs-fleet", namespace: "preview", replicas: 64, availableCount: 60, phase: "Degraded" }, + { name: "canary-fleet", namespace: "preview", replicas: 8, availableCount: 8, phase: "Ready" }, + { name: "infra-maint-fleet", namespace: "preview", replicas: 12, availableCount: 10, phase: "Degraded" }, + { name: "data-pipeline-fleet", namespace: "preview", replicas: 24, availableCount: 22, phase: "Ready" }, + { name: "browser-agent-fleet", namespace: "preview", replicas: 32, availableCount: 31, phase: "Ready" }, + { name: "desktop-agent-fleet", namespace: "preview", replicas: 18, availableCount: 17, phase: "Ready" }, +] + +export async function listLocalVisualPreviewPools(): Promise { + const state = new URLSearchParams(window.location.search).get( + "cua-preview-state", + ) + if (state === "loading") { + await new Promise(resolve => window.setTimeout(resolve, 2_000)) + } + if (state === "empty") return [] + if (state === "error") throw new Error("Synthetic preview error") + return localVisualPreviewPools +} + +export function getLocalVisualPreviewPool( + namespace: string, + name: string, +): PoolData | undefined { + const summary = localVisualPreviewPools.find( + pool => pool.namespace === namespace && pool.name === name, + ) + if (!summary) return undefined + + return { + ...summary, + cpu: 4, + ram: "8Gi", + ociImage: "ghcr.io/trycua/cua:latest", + firmware: "efi", + services: [ + { name: "desktop", targetPort: 6901, protocol: "TCP" }, + { name: "ssh", targetPort: 22, protocol: "TCP" }, + ], + probes: {}, + autoscaling: { + minPoolSize: Math.max(1, Math.floor(summary.replicas * 0.25)), + initialPoolSize: summary.replicas, + maxPoolSize: Math.ceil(summary.replicas * 1.5), + }, + totalCount: summary.replicas, + claimedCount: Math.max(summary.replicas - summary.availableCount, 0), + } +} + +const localVisualPreviewTimestamp = "2026-08-16T20:20:00.000Z" + +const localVisualPreviewConversations: Conversation[] = [ + { + id: "preview-browser-task", + title: "Inspect the browser fleet", + created_at: localVisualPreviewTimestamp, + updated_at: localVisualPreviewTimestamp, + messages: [ + { + id: "preview-message-1", + role: "user", + content: "Check the browser fleet and summarize its availability.", + created_at: localVisualPreviewTimestamp, + }, + { + id: "preview-message-2", + role: "assistant", + content: "", + created_at: localVisualPreviewTimestamp, + tool_calls: [ + { + id: "preview-tool-1", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "cua pools list --selector fleet=browser", + }), + }, + }, + ], + }, + { + id: "preview-message-3", + role: "tool", + tool_call_id: "preview-tool-1", + content: JSON.stringify({ + stdout: "browser-agent-fleet 31/32 available Ready", + stderr: "", + exit_code: 0, + timed_out: false, + truncated: false, + }), + created_at: localVisualPreviewTimestamp, + }, + { + id: "preview-message-4", + role: "assistant", + content: + "The browser fleet is healthy: **31 of 32** workspaces are available. One workspace is currently being replenished.", + created_at: localVisualPreviewTimestamp, + }, + ], + }, + { + id: "preview-pool-task", + title: "Create a staging pool", + created_at: "2026-08-15T18:10:00.000Z", + updated_at: "2026-08-15T18:10:00.000Z", + messages: [], + }, +] + +export async function listLocalVisualPreviewConversations(): Promise< + ConversationSummary[] +> { + const state = new URLSearchParams(window.location.search).get( + "cua-preview-state", + ) + if (state === "loading") { + await new Promise(resolve => window.setTimeout(resolve, 2_000)) + } + if (state === "empty") return [] + return localVisualPreviewConversations.map(({ messages: _messages, ...summary }) => ({ + ...summary, + })) +} + +export function getLocalVisualPreviewConversation( + id: string, +): Conversation | undefined { + return localVisualPreviewConversations.find(conversation => conversation.id === id) +} + +export function createLocalVisualPreviewConversation(): Conversation { + const timestamp = new Date().toISOString() + const conversation: Conversation = { + id: `preview-conversation-${localVisualPreviewConversations.length + 1}`, + title: "New conversation", + created_at: timestamp, + updated_at: timestamp, + messages: [], + } + localVisualPreviewConversations.unshift(conversation) + return conversation +} + +export async function streamLocalVisualPreviewTurn( + conversationId: string, + messages: ChatMessage[], + onDelta: (delta: string) => void, +): Promise { + const conversation = getLocalVisualPreviewConversation(conversationId) + if (!conversation) throw new Error("Preview conversation not found") + + const timestamp = new Date().toISOString() + const content = + "Preview mode is connected. In a signed-in environment, Cua would run this request against your available tools." + for (const message of messages) { + conversation.messages.push({ + ...message, + id: message.id ?? `preview-message-${conversation.messages.length + 1}`, + created_at: message.created_at ?? timestamp, + }) + } + onDelta(content) + const assistant: ChatMessage = { + id: `preview-message-${conversation.messages.length + 1}`, + role: "assistant", + content, + created_at: timestamp, + } + conversation.messages.push(assistant) + conversation.updated_at = timestamp + return assistant +} diff --git a/libs/fleet/src/main.tsx b/libs/fleet/src/main.tsx index 07f9c6e074..0af9f9ca7f 100644 --- a/libs/fleet/src/main.tsx +++ b/libs/fleet/src/main.tsx @@ -1,12 +1,58 @@ import React from "react" import ReactDOM from "react-dom/client" +import "@cua/design/dashboard.css" import "@cloudscape-design/global-styles/index.css" import { applyMode, Mode } from "@cloudscape-design/global-styles" +import { applyTheme } from "@cloudscape-design/components/theming" +import urbanistFont from "@cua/design/assets/fonts/urbanist-normal-latin.woff2" +import monoFont from "@cua/design/assets/fonts/jetbrains-mono-normal-latin.woff2" +import displayFont from "@cua/design/assets/fonts/instrument-serif-normal-latin.woff2" import { App } from "./App" import { AuthProvider } from "./auth/AuthProvider" import { DeviceAuthorization } from "./pages/DeviceAuthorization" +import "./shell.css" applyMode(Mode.Dark) +document.documentElement.classList.add("cua-dashboard-theme") +document.body.id = "cua-dashboard-root" +applyTheme({ + theme: { + tokens: { + colorBackgroundLayoutMain: "#000000", + colorBackgroundLayoutToolbar: "#000000", + colorBackgroundContainerContent: "#181818", + colorBackgroundContainerHeader: "#181818", + colorBackgroundButtonPrimaryDefault: "#9fd7ff", + colorBackgroundButtonPrimaryHover: "#b7dcff", + colorBackgroundButtonPrimaryActive: "#ecf6ff", + colorBorderButtonPrimaryDefault: "#9fd7ff", + colorBorderButtonPrimaryHover: "#b7dcff", + colorBorderButtonPrimaryActive: "#ecf6ff", + colorTextButtonPrimaryDefault: "#000000", + colorTextButtonPrimaryHover: "#000000", + colorTextButtonPrimaryActive: "#000000", + colorTextBodyDefault: "#f6f8fb", + colorTextBodySecondary: "rgba(224, 230, 238, 0.84)", + colorTextHeadingDefault: "#f6f8fb", + colorTextHeadingSecondary: "rgba(224, 230, 238, 0.84)", + colorTextLinkDefault: "#9fd7ff", + colorTextLinkHover: "#ecf6ff", + colorBorderDividerDefault: "rgba(255, 255, 255, 0.12)", + colorBorderItemFocused: "#9fd7ff", + fontFamilyBase: '"Urbanist", -apple-system, BlinkMacSystemFont, sans-serif', + }, + }, +}) + +for (const href of [urbanistFont, monoFont, displayFont]) { + const link = document.createElement("link") + link.rel = "preload" + link.as = "font" + link.type = "font/woff2" + link.crossOrigin = "anonymous" + link.href = href + document.head.appendChild(link) +} const isDeviceAuthorization = window.location.pathname === "/device" diff --git a/libs/fleet/src/pages/AgentChat.css b/libs/fleet/src/pages/AgentChat.css index 18cd9be61e..161cb5bab3 100644 --- a/libs/fleet/src/pages/AgentChat.css +++ b/libs/fleet/src/pages/AgentChat.css @@ -2,12 +2,19 @@ display: grid; gap: 24px; grid-template-columns: minmax(230px, 300px) minmax(0, 1fr); - height: calc(100vh - 160px); + height: calc(100dvh - var(--cua-chat-viewport-offset, 160px)); + margin-block-end: calc(-1 * var(--cua-layout-application-inline)); min-height: 560px; } +@supports not (height: 100dvh) { + .agent-chat-page { + height: calc(100vh - var(--cua-chat-viewport-offset, 160px)); + } +} + .agent-chat-history { - border-inline-end: 1px solid var(--color-border-divider-default, #d5dbdb); + border-inline-end: 1px solid var(--cua-color-line-soft); min-width: 0; overflow-y: auto; padding-inline-end: 20px; @@ -46,7 +53,7 @@ } .agent-chat-composer { - background: linear-gradient(180deg, transparent, var(--color-background-layout-main, #0f1b2a) 28%); + background: linear-gradient(180deg, transparent, var(--cua-dashboard-background) 28%); bottom: 0; padding: 24px 8px 12px; position: sticky; @@ -63,7 +70,7 @@ } .agent-chat-timestamp { - color: var(--color-text-body-secondary, #5f6b7a); + color: var(--cua-color-text-muted); font-size: 0.75rem; } @@ -71,12 +78,12 @@ animation: agent-chat-shimmer 1.4s ease-in-out infinite; background: linear-gradient( 90deg, - var(--color-background-layout-toggle-default, #eaeded) 25%, - var(--color-background-layout-toggle-hover, #f2f3f3) 50%, - var(--color-background-layout-toggle-default, #eaeded) 75% + var(--cua-color-surface-elevated-1) 25%, + var(--cua-color-surface-elevated-2) 50%, + var(--cua-color-surface-elevated-1) 75% ); background-size: 200% 100%; - border-radius: 8px; + border-radius: var(--cua-radius-control); } .agent-chat-skeleton--conversation { @@ -109,8 +116,12 @@ @media (max-width: 700px) { .agent-chat-page { display: block; - height: calc(100vh - 120px); - min-height: 480px; + min-height: 0; + } + + .agent-chat-main { + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100%; } .agent-chat-history { @@ -142,7 +153,7 @@ .agent-chat-mobile-overlay { align-items: stretch; - background: rgb(0 0 0 / 45%); + background: var(--cua-color-scrim-overlay); display: flex; inset: 0; position: fixed; @@ -161,8 +172,8 @@ .agent-chat-mobile-drawer { position: relative; z-index: 1; - background: var(--color-background-layout-main, #fff); - box-shadow: 2px 0 12px rgb(0 0 0 / 25%); + background: var(--cua-color-surface-elevated-1); + box-shadow: var(--cua-shadow-medium); max-width: min(360px, 92vw); min-width: min(320px, 92vw); overflow-y: auto; @@ -175,7 +186,7 @@ } .agent-chat-command { - border-inline-start: 3px solid var(--color-border-status-info, #3184c2); + border-inline-start: 3px solid var(--cua-color-status-info); margin-inline: 16px; padding: 8px 12px; } @@ -226,8 +237,8 @@ } .agent-chat-markdown blockquote { - border-inline-start: 3px solid var(--color-border-divider-default, #5f6b7a); - color: var(--color-text-body-secondary, #8d99a8); + border-inline-start: 3px solid var(--cua-color-line-strong); + color: var(--cua-color-text-muted); padding-inline-start: 0.75rem; } @@ -238,28 +249,28 @@ .agent-chat-markdown th, .agent-chat-markdown td { - border: 1px solid var(--color-border-divider-default, #5f6b7a); + border: 1px solid var(--cua-color-line-soft); padding: 0.4rem 0.6rem; text-align: start; vertical-align: top; } .agent-chat-markdown th { - background: var(--color-background-container-header, rgb(255 255 255 / 6%)); + background: var(--cua-color-surface-elevated-2); font-weight: 700; } .agent-chat-markdown code { - background: var(--color-background-layout-toggle-default, rgb(255 255 255 / 8%)); + background: var(--cua-color-surface-elevated-1); border-radius: 4px; - font-family: "Fira Code", "Cascadia Code", monospace; + font-family: var(--cua-font-mono); font-size: 0.875em; padding: 0.12em 0.3em; } .agent-chat-markdown pre { - background: var(--color-background-layout-main, #0f1b2a); - border: 1px solid var(--color-border-divider-default, #5f6b7a); + background: var(--cua-color-surface-base); + border: 1px solid var(--cua-color-line-soft); border-radius: 8px; max-width: 100%; overflow-x: auto; @@ -272,7 +283,7 @@ } .agent-chat-markdown a { - color: var(--color-text-link-default, #539fe5); + color: var(--cua-color-brand-primary); text-decoration: underline; text-underline-offset: 0.15em; } diff --git a/libs/fleet/src/pages/AgentChat.tsx b/libs/fleet/src/pages/AgentChat.tsx index 10e2812076..dd1d4756f7 100644 --- a/libs/fleet/src/pages/AgentChat.tsx +++ b/libs/fleet/src/pages/AgentChat.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react" +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import Avatar from "@cloudscape-design/chat-components/avatar" import ChatBubble from "@cloudscape-design/chat-components/chat-bubble" @@ -14,6 +14,7 @@ import PromptInput from "@cloudscape-design/components/prompt-input" import SpaceBetween from "@cloudscape-design/components/space-between" import StatusIndicator from "@cloudscape-design/components/status-indicator" import { MarkdownMessage } from "../components/MarkdownMessage" +import { PageShell } from "../components/PageShell" import { createClaim, deleteClaim, getClaim, listClaims } from "../sdk/claims" import { createPool, deletePool, getPool, listNamespaces, listPools, updatePoolServices } from "../sdk/pools" import { createUserKey, deleteUserKey, listUserKeys } from "../sdk/userKeys" @@ -206,7 +207,7 @@ function MessageBubble({ message, position, loading = false }: { message: ChatMe const author = incoming ? "Assistant" : "You" const time = formatTime(message.created_at) return ( - : } type={incoming ? "incoming" : "outgoing"}> + : } type={incoming ? "incoming" : "outgoing"}> {loading && !message.content ?
Generating a response
: } {!loading && {author} - {time.local}} @@ -258,6 +259,7 @@ export function AgentChat() { const [refreshError, setRefreshError] = useState() const [initialCreatePrompt, setInitialCreatePrompt] = useState() const mobileHistory = useMobileHistory() + const pageRef = useRef(null) const triggerRef = useRef(null) const closeButtonRef = useRef(null) const dialogRef = useRef(null) @@ -273,6 +275,28 @@ export function AgentChat() { const selectedConversationIdRef = useRef() const loadingRunIdRef = useRef() + useLayoutEffect(() => { + const element = pageRef.current + if (!element) return + + const measure = () => { + const box = element.getBoundingClientRect() + const top = Math.max(0, Math.round(box.top)) + element.style.setProperty( + "--cua-chat-viewport-offset", + `${top + 16}px`, + ) + } + measure() + const observer = new ResizeObserver(measure) + observer.observe(document.documentElement) + window.addEventListener("resize", measure) + return () => { + observer.disconnect() + window.removeEventListener("resize", measure) + } + }, []) + if (!agentRef.current) { agentRef.current = new BrowserBashAgent(async (conversationID, messages, signal, onDelta) => { const message = await streamTurn(conversationID, messages, onDelta ?? (() => undefined), signal) @@ -542,9 +566,20 @@ export function AgentChat() { ? `Latest ${effectiveMessage.role === "user" ? "user" : "assistant"} message available.` : "" const drawerHistory = { if (selectConversation(id)) closeHistory() }} showHeading={false} /> + const firstConversation = !listLoading && conversations.length === 0 return ( -
+ <> + +
@@ -555,7 +590,7 @@ export function AgentChat() { {liveAssistant && } {!liveAssistant && assistantLoading && } - ) :
Select a conversationChoose a previous conversation to view its transcript.
} + ) :
{firstConversation ? "Start a conversation" : "Select a conversation"}{firstConversation ? "Ask a question below to create your first conversation." : "Choose a previous conversation to view its transcript."}
} {turnError &&
Retry} type="error">{turnError}
} {refreshError &&
{ if (selectedId) void recoverRefresh(selectedId) }}>Refresh conversation} type="error">{refreshError}
}
@@ -570,12 +605,14 @@ export function AgentChat() {
+
+ {mobileHistory && historyOpen && createPortal(
, document.body, )} -
+ ) } diff --git a/libs/fleet/src/pages/Billing.tsx b/libs/fleet/src/pages/Billing.tsx index 9653f4af89..799a9ac1da 100644 --- a/libs/fleet/src/pages/Billing.tsx +++ b/libs/fleet/src/pages/Billing.tsx @@ -3,12 +3,12 @@ import Alert from "@cloudscape-design/components/alert"; import Box from "@cloudscape-design/components/box"; -import Button from "@cloudscape-design/components/button"; import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Spinner from "@cloudscape-design/components/spinner"; import { useEffect, useState } from "react"; +import { CuaButton } from "../components/CuaButton"; import { billingApi, type BillingSummary } from "../sdk/billing"; function cardBrand(brand: string) { @@ -61,8 +61,8 @@ export function BillingSettings() { } footer={ summary && ( - + ) } > diff --git a/libs/fleet/src/pages/ClaimDetail.tsx b/libs/fleet/src/pages/ClaimDetail.tsx index 4083fec801..da5cd44c3d 100644 --- a/libs/fleet/src/pages/ClaimDetail.tsx +++ b/libs/fleet/src/pages/ClaimDetail.tsx @@ -1,20 +1,21 @@ import { useEffect, useState } from "react" import { useNavigate, useParams } from "react-router-dom" import Box from "@cloudscape-design/components/box" -import Button from "@cloudscape-design/components/button" import ColumnLayout from "@cloudscape-design/components/column-layout" import Container from "@cloudscape-design/components/container" import Header from "@cloudscape-design/components/header" import Link from "@cloudscape-design/components/link" import Modal from "@cloudscape-design/components/modal" import SpaceBetween from "@cloudscape-design/components/space-between" -import Spinner from "@cloudscape-design/components/spinner" import StatusIndicator from "@cloudscape-design/components/status-indicator" import { useFlash } from "../components/FlashContext" import { DesktopPane } from "../components/DesktopPane" import { deleteClaim, getClaim } from "../sdk/claims" import type { Claim } from "../sdk/models" import { getPool } from "../sdk/pools" +import { CuaButton } from "../components/CuaButton" +import { PageEmpty, PageError } from "../components/PageState" +import { PageShell } from "../components/PageShell" function phaseType(phase: string): "success" | "pending" | "error" | "info" { switch (phase) { @@ -39,9 +40,11 @@ export function ClaimDetail() { const [loading, setLoading] = useState(true) const [confirmRelease, setConfirmRelease] = useState(false) const [releasing, setReleasing] = useState(false) + const [loadError, setLoadError] = useState(null) const load = async () => { setLoading(true) + setLoadError(null) try { const [claimData, poolData] = await Promise.all([ getClaim(namespace, claimName), @@ -50,10 +53,12 @@ export function ClaimDetail() { setClaim(claimData) setServices(poolData.services) } catch (e) { + const message = String((e as Error).message) + setLoadError(message) flash.push({ type: "error", header: "Failed to load claim", - content: String((e as Error).message), + content: message, }) } finally { setLoading(false) @@ -99,40 +104,55 @@ export function ClaimDetail() { if (loading && !claim) { return ( - {claimName}}> - - Loading claim… - - + + + + ) + } + if (!claim) { + return ( + + Try again} + > + {loadError} + + ) } - if (!claim) return null const sandboxName = claim.sandboxName const isBound = claim.phase === "Bound" && sandboxName return ( - - - - - - } - > - {claimName} - - } - > + + {claim.phase} + + } + secondaryActions={ + + + navigate(`/pools/${namespace}/${poolName}`)}> + Back to pool + + setConfirmRelease(true)}> + Release + + + } + > + + Overview}>
Status @@ -225,12 +245,12 @@ export function ClaimDetail() { footer={ - - + } @@ -238,6 +258,7 @@ export function ClaimDetail() { This will delete the claim and return its sandbox VM back to the warm pool. The VM will be restarted with a clean state. - + + ) } diff --git a/libs/fleet/src/pages/PoolDetail.tsx b/libs/fleet/src/pages/PoolDetail.tsx index 3324039b36..2c45dd22a7 100644 --- a/libs/fleet/src/pages/PoolDetail.tsx +++ b/libs/fleet/src/pages/PoolDetail.tsx @@ -10,7 +10,6 @@ import Link from "@cloudscape-design/components/link" import Modal from "@cloudscape-design/components/modal" import Select from "@cloudscape-design/components/select" import SpaceBetween from "@cloudscape-design/components/space-between" -import Spinner from "@cloudscape-design/components/spinner" import StatusIndicator from "@cloudscape-design/components/status-indicator" import Table from "@cloudscape-design/components/table" import Tabs from "@cloudscape-design/components/tabs" @@ -20,6 +19,9 @@ import { createClaim as createSdkClaim, deleteClaim, listClaims } from "../sdk/c import type { Claim, PoolData } from "../sdk/models" import { deletePool, getPool, updatePoolServices } from "../sdk/pools" import { derivePoolStatus, tombstonePool } from "../sdk/status" +import { CuaButton } from "../components/CuaButton" +import { PageEmpty, PageError } from "../components/PageState" +import { PageShell } from "../components/PageShell" export function PoolDetail() { const { namespace = "", name = "" } = useParams() @@ -30,17 +32,21 @@ export function PoolDetail() { const [loading, setLoading] = useState(true) const [confirmingDelete, setConfirmingDelete] = useState(false) const [deleting, setDeleting] = useState(false) + const [loadError, setLoadError] = useState(null) const load = async () => { setLoading(true) + setLoadError(null) try { const p = await getPool(namespace, name) setPool(p) } catch (e) { + const message = String((e as Error).message) + setLoadError(message) flash.push({ type: "error", header: `Failed to load pool "${name}"`, - content: String((e as Error).message), + content: message, }) } finally { setLoading(false) @@ -70,43 +76,54 @@ export function PoolDetail() { if (loading && !pool) { return ( - {name}}> - - Loading pool… - - + + + + ) + } + if (!pool) { + return ( + + Try again} + > + {loadError} + + ) } - if (!pool) return null const status = derivePoolStatus(pool) return ( - - - - - + } + secondaryActions={ + + + + navigate("/pools/new", { state: { source: pool } }) } > - {pool.name} - - } - > + Duplicate + + setConfirmingDelete(true)}> + Delete + + + } + > + + Overview}>
Status @@ -141,12 +158,12 @@ export function PoolDetail() { footer={ - - + } @@ -155,7 +172,8 @@ export function PoolDetail() { in {pool.namespace}. - + + ) } @@ -334,12 +352,12 @@ function ServicesEditor({ variant="h2" actions={ - - + } > @@ -375,7 +393,12 @@ function ServicesEditor({ } options={PROTOCOL_OPTIONS} /> - + } > @@ -568,19 +596,19 @@ function ClaimsTable({ pool }: { pool: PoolData }) { footer={ - - + } @@ -605,19 +633,19 @@ function ClaimsTable({ pool }: { pool: PoolData }) { footer={ - - + } diff --git a/libs/fleet/src/pages/PoolNew.tsx b/libs/fleet/src/pages/PoolNew.tsx index cd57d8390f..ad2a63e193 100644 --- a/libs/fleet/src/pages/PoolNew.tsx +++ b/libs/fleet/src/pages/PoolNew.tsx @@ -1,19 +1,21 @@ -import { useMemo, useState } from "react" -import { useLocation, useNavigate } from "react-router-dom" +import { useMemo, useRef, useState } from "react" +import { useBeforeUnload, useLocation, useNavigate } from "react-router-dom" import Box from "@cloudscape-design/components/box" -import Button from "@cloudscape-design/components/button" import ColumnLayout from "@cloudscape-design/components/column-layout" import Container from "@cloudscape-design/components/container" import Form from "@cloudscape-design/components/form" import FormField from "@cloudscape-design/components/form-field" import Header from "@cloudscape-design/components/header" -import Input from "@cloudscape-design/components/input" +import Input, { type InputProps } from "@cloudscape-design/components/input" +import Modal from "@cloudscape-design/components/modal" import Select from "@cloudscape-design/components/select" import SpaceBetween from "@cloudscape-design/components/space-between" import Toggle from "@cloudscape-design/components/toggle" import { useFlash } from "../components/FlashContext" import { createPool } from "../sdk/pools" import type { PoolTemplateConfig } from "../sdk/models" +import { CuaButton } from "../components/CuaButton" +import { PageShell } from "../components/PageShell" const NAME_PATTERN = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/ @@ -106,16 +108,26 @@ export function PoolNew() { const [initialPoolSize, setInitialPoolSize] = useState(String(seed.autoscaling?.initialPoolSize ?? 0)) const [maxPoolSize, setMaxPoolSize] = useState(String(seed.autoscaling?.maxPoolSize ?? 20)) const [submitting, setSubmitting] = useState(false) + const [dirty, setDirty] = useState(false) + const [submitAttempted, setSubmitAttempted] = useState(false) + const [discardOpen, setDiscardOpen] = useState(false) + const nameRef = useRef(null) + + useBeforeUnload(event => { + if (!dirty || submitting) return + event.preventDefault() + }) const nameError = useMemo(() => { - if (!name) return undefined + if (!name) return submitAttempted ? "Name is required." : undefined if (!NAME_PATTERN.test(name)) { return "Lowercase letters, digits, and dashes only; no leading/trailing dash." } return undefined - }, [name]) + }, [name, submitAttempted]) const addService = () => { + setDirty(true) setServices(prev => [...prev, { id: genId(), name: "", targetPort: "", protocol: "TCP" }]) } @@ -124,6 +136,7 @@ export function PoolNew() { } const removeService = (id: string) => { + setDirty(true) setServices(prev => prev.filter(s => s.id !== id)) } @@ -165,11 +178,16 @@ export function PoolNew() { } const create = async () => { - if (!name || nameError) return + setSubmitAttempted(true) + if (!name || nameError) { + nameRef.current?.focus() + return + } setSubmitting(true) try { await createPool(name, buildValues()) flash.push({ type: "success", header: `Created pool ${name}` }) + setDirty(false) // Pool name = namespace name (1:1 mapping). navigate(`/pools/${name}/${name}`) } catch (e) { @@ -183,38 +201,30 @@ export function PoolNew() { } } - const submitDisabled = !name || !!nameError || submitting + const cancel = () => { + if (dirty) setDiscardOpen(true) + else navigate("/pools") + } return ( - - {source ? "Duplicate pool" : "New pool"} - + Cancel} + primaryAction={ + + Create + } > -
- - - - } - > + Configuration}> +
setDirty(true)}> + setName(detail.value)} placeholder="my-pool" @@ -373,19 +384,51 @@ export function PoolNew() { onChange={({ detail }) => updateService(svc.id, "protocol", detail.selectedOption.value ?? "TCP")} options={PROTOCOL_OPTIONS} /> - + - +
+
+ setDiscardOpen(false)} + header="Discard changes?" + footer={ + + + setDiscardOpen(false)}> + Keep editing + + { + setDirty(false) + navigate("/pools") + }} + > + Discard + + + + } + > + Your unsaved pool configuration will be lost. + +
) } diff --git a/libs/fleet/src/pages/PoolsList.tsx b/libs/fleet/src/pages/PoolsList.tsx index 77e97bf46f..ba7d58acf2 100644 --- a/libs/fleet/src/pages/PoolsList.tsx +++ b/libs/fleet/src/pages/PoolsList.tsx @@ -2,14 +2,15 @@ import { useEffect, useState } from "react" import { useNavigate } from "react-router-dom" import { useCollection } from "@cloudscape-design/collection-hooks" import Box from "@cloudscape-design/components/box" -import Button from "@cloudscape-design/components/button" -import Header from "@cloudscape-design/components/header" import Link from "@cloudscape-design/components/link" +import Modal from "@cloudscape-design/components/modal" +import designTokens from "@cua/design/tokens.json" import Pagination from "@cloudscape-design/components/pagination" import PropertyFilter from "@cloudscape-design/components/property-filter" import SpaceBetween from "@cloudscape-design/components/space-between" import Table from "@cloudscape-design/components/table" import { deletePool, getPool, listPools } from "../sdk/pools" +import { localVisualPreviewPath } from "../local-visual-preview" import { derivePoolStatus, reconcileTombstones, @@ -17,6 +18,9 @@ import { } from "../sdk/status" import { PoolStatusPill } from "../components/PoolStatus" import { useFlash } from "../components/FlashContext" +import { CuaButton } from "../components/CuaButton" +import { PageEmpty } from "../components/PageState" +import { PageShell } from "../components/PageShell" interface PoolRow { name: string @@ -35,6 +39,21 @@ export function PoolsList() { const [selected, setSelected] = useState([]) const [busy, setBusy] = useState(false) const [duplicating, setDuplicating] = useState(false) + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) + const [compactTable, setCompactTable] = useState( + () => window.matchMedia( + `(max-width: ${designTokens.layout.breakpoint.mobile - 1}px)`, + ).matches, + ) + + useEffect(() => { + const query = window.matchMedia( + `(max-width: ${designTokens.layout.breakpoint.mobile - 1}px)`, + ) + const update = () => setCompactTable(query.matches) + query.addEventListener("change", update) + return () => query.removeEventListener("change", update) + }, []) const load = async () => { setLoading(true) @@ -72,6 +91,7 @@ export function PoolsList() { header: `Deleted ${selected.length} pool${selected.length > 1 ? "s" : ""}`, }) setSelected([]) + setConfirmDeleteOpen(false) await load() } catch (e) { flash.push({ @@ -112,7 +132,6 @@ export function PoolsList() { propertyFilterProps, filteredItemsCount, paginationProps, - actions, } = useCollection(rows, { propertyFiltering: { filteringProperties: [ @@ -121,25 +140,19 @@ export function PoolsList() { { key: "statusText", propertyLabel: "Status", groupValuesLabel: "Status values", operators: ["=", "!="] }, ], empty: ( - - - No pools - - Create one to get started. - - - - + navigate("/pools/new")}> + New pool + + } + > + Create one to get started. + ), noMatch: ( - - - No matches - - - + ), }, pagination: { pageSize: 25 }, @@ -147,41 +160,72 @@ export function PoolsList() { }) return ( - + + + } + primaryAction={ + navigate(localVisualPreviewPath("/pools/new"))} + > + New pool + + } + > + {selected.length > 0 ? ( +
+ + {selected.length} pool{selected.length === 1 ? "" : "s"} selected + + + + Duplicate + + setConfirmDeleteOpen(true)} + > + Delete + + +
+ ) : null} +
+
- - - - - } - > - Pools - - } + ariaLabels={{ + tableLabel: "Pools", + selectionGroupLabel: "Pool selection", + allItemsSelectionLabel: ({ selectedItems }) => + selectedItems.length === items.length && items.length > 0 + ? "Deselect all pools" + : "Select all pools", + itemSelectionLabel: ({ selectedItems }, item) => + selectedItems.includes(item) + ? `Deselect pool ${item.name}` + : `Select pool ${item.name}`, + }} + variant="borderless" items={items} loading={loading} loadingText="Loading pools" @@ -204,44 +248,106 @@ export function PoolsList() { }} /> } - pagination={} + pagination={ + `Page ${pageNumber}`, + }} + /> + } + stickyColumns={{ first: 1, last: 1 }} + columnDisplay={[ + { id: "name", visible: true }, + { id: "replicas", visible: !compactTable }, + { id: "available", visible: !compactTable }, + { id: "capacity", visible: compactTable }, + { id: "status", visible: true }, + ]} columnDefinitions={[ { id: "name", header: "Name", cell: p => ( { e.preventDefault() - navigate(`/pools/${p.namespace}/${p.name}`) + navigate( + localVisualPreviewPath(`/pools/${p.namespace}/${p.name}`), + ) }} > - {p.name} + {p.name} ), sortingField: "name", isRowHeader: true, + minWidth: compactTable ? 112 : 220, + width: compactTable ? 120 : undefined, + maxWidth: compactTable ? 120 : undefined, }, { id: "replicas", header: "Replicas", cell: p => p.replicas, sortingField: "replicas", + minWidth: 96, }, { id: "available", header: "Available", cell: p => p.availableCount, sortingField: "availableCount", + minWidth: 96, + }, + { + id: "capacity", + header: "Available", + cell: p => `${p.availableCount}/${p.replicas}`, + minWidth: 72, + width: 72, + maxWidth: 72, }, { id: "status", header: "Status", - cell: p => , - sortingField: "statusText", + cell: p => ( + + ), + sortingField: compactTable ? undefined : "statusText", + minWidth: compactTable ? 60 : 140, + width: compactTable ? 60 : undefined, + maxWidth: compactTable ? 60 : undefined, }, ]} - /> + /> + + setConfirmDeleteOpen(false)} + header={`Delete ${selected.length} pool${selected.length === 1 ? "" : "s"}?`} + footer={ + + + setConfirmDeleteOpen(false)}> + Cancel + + + Delete + + + + } + > + This action cannot be undone. + + ) } diff --git a/libs/fleet/src/pages/Settings.tsx b/libs/fleet/src/pages/Settings.tsx index adf61e7b30..9d118ddc9c 100644 --- a/libs/fleet/src/pages/Settings.tsx +++ b/libs/fleet/src/pages/Settings.tsx @@ -2,9 +2,9 @@ // policies for backend automation. import { useEffect, useMemo, useState } from "react" +import { useNavigate } from "react-router-dom" import Alert from "@cloudscape-design/components/alert" import Box from "@cloudscape-design/components/box" -import Button from "@cloudscape-design/components/button" import Container from "@cloudscape-design/components/container" import CopyToClipboard from "@cloudscape-design/components/copy-to-clipboard" import ExpandableSection from "@cloudscape-design/components/expandable-section" @@ -20,7 +20,13 @@ import SpaceBetween from "@cloudscape-design/components/space-between" import Table from "@cloudscape-design/components/table" import Toggle from "@cloudscape-design/components/toggle" import { userInfo } from "../auth/keycloak" +import { errorMessage } from "../error-message" +import { CuaButton } from "../components/CuaButton" import { useFeatureFlags } from "../components/FeatureFlagContext" +import { useFlash } from "../components/FlashContext" +import { PageEmpty, PageError } from "../components/PageState" +import { PageShell } from "../components/PageShell" +import { localVisualPreviewPath } from "../local-visual-preview" import { type GitHubTrustPolicy, githubTrustPoliciesApi, @@ -29,6 +35,8 @@ import { import { BillingSettings } from "./Billing" export function Settings() { + const navigate = useNavigate() + const flash = useFlash() const { sub, name } = userInfo() const { billing } = useFeatureFlags() const [policies, setPolicies] = useState([]) @@ -45,7 +53,7 @@ export function Settings() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [busyId, setBusyId] = useState(null) - const [error, setError] = useState(null) + const [loadError, setLoadError] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const allNamespaceOptions = useMemo(() => { @@ -76,9 +84,9 @@ export function Settings() { value: ns.name, })), ) - setError(null) + setLoadError(null) } catch (e) { - setError(String(e)) + setLoadError(errorMessage(e)) } finally { setLoading(false) } @@ -116,10 +124,21 @@ export function Settings() { } else { await githubTrustPoliciesApi.create(payload) } + flash.push({ + type: "success", + header: editingId ? "Trust policy saved" : "Trust policy created", + content: "The change is stored remotely and applies immediately.", + }) resetForm() await refresh() } catch (e) { - setError(String(e)) + flash.push({ + type: "error", + header: editingId + ? "Failed to save trust policy" + : "Failed to create trust policy", + content: errorMessage(e), + }) } finally { setSaving(false) } @@ -141,9 +160,18 @@ export function Settings() { await githubTrustPoliciesApi.update(policy.id, { enabled: !policy.enabled, }) + flash.push({ + type: "success", + header: policy.enabled ? "Trust policy disabled" : "Trust policy enabled", + content: "The change is stored remotely and applies immediately.", + }) await refresh() } catch (e) { - setError(String(e)) + flash.push({ + type: "error", + header: "Failed to update trust policy", + content: errorMessage(e), + }) } finally { setBusyId(null) } @@ -154,11 +182,20 @@ export function Settings() { setBusyId(confirmDelete.id) try { await githubTrustPoliciesApi.remove(confirmDelete.id) + flash.push({ + type: "success", + header: "Trust policy deleted", + content: "GitHub workflows using it can no longer authenticate.", + }) setConfirmDelete(null) if (editingId === confirmDelete.id) resetForm() await refresh() } catch (e) { - setError(String(e)) + flash.push({ + type: "error", + header: "Failed to delete trust policy", + content: errorMessage(e), + }) } finally { setBusyId(null) } @@ -205,15 +242,23 @@ steps: --name "$sandbox" cua sb exec "$sandbox" sh -lc 'uname -a; id; pwd'` return ( - - {error && ( - setError(null)}> - {error} - - )} - - Account}> - + + + + Account + + } + > + {name ? ( Unknown )} - - + + - {billing && } + navigate(localVisualPreviewPath("/user-keys"))} + > + Manage API keys + + } + > + API keys + + } + > + + Create scoped credentials for local tools, CI workflows, and other + automation that calls Cua on your behalf. + + - + {loadError ? ( + Retry} + > + {loadError} + + ) : ( +
- {editingId && } - +
} > @@ -320,7 +397,9 @@ steps:
policy.name }, @@ -349,24 +428,34 @@ steps: header: "", cell: policy => ( - - - + ), }, ]} empty={ - - No GitHub trust policies yet. - + + Create a policy when a repository needs short-lived access to Fleets. + } header={
Configured policies
} /> @@ -416,9 +505,12 @@ steps: - + + )} + {billing && } + {confirmDelete && ( - - + } @@ -444,6 +536,7 @@ steps: this policy will lose access immediately. )} - + + ) } diff --git a/libs/fleet/src/pages/UserApiKeys.tsx b/libs/fleet/src/pages/UserApiKeys.tsx index ebd375ec17..043aefc096 100644 --- a/libs/fleet/src/pages/UserApiKeys.tsx +++ b/libs/fleet/src/pages/UserApiKeys.tsx @@ -6,9 +6,9 @@ // returned once on creation and cannot be retrieved later. import { useEffect, useState } from "react" +import designTokens from "@cua/design/tokens.json" import Alert from "@cloudscape-design/components/alert" import Box from "@cloudscape-design/components/box" -import Button from "@cloudscape-design/components/button" import Container from "@cloudscape-design/components/container" import CopyToClipboard from "@cloudscape-design/components/copy-to-clipboard" import Form from "@cloudscape-design/components/form" @@ -29,11 +29,17 @@ import { type UserApiKey, } from "../sdk/userKeys" import { listNamespaces } from "../sdk/pools" +import { errorMessage } from "../error-message" +import { CuaButton } from "../components/CuaButton" +import { useFlash } from "../components/FlashContext" +import { PageEmpty, PageError } from "../components/PageState" +import { PageShell } from "../components/PageShell" export function UserApiKeys() { + const flash = useFlash() const [keys, setKeys] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [loadError, setLoadError] = useState(null) const [name, setName] = useState("") const [selectedScope, setSelectedScope] = useState< MultiselectProps.Option[] @@ -42,6 +48,21 @@ export function UserApiKeys() { const [creating, setCreating] = useState(false) const [created, setCreated] = useState(null) const [confirmRevoke, setConfirmRevoke] = useState(null) + const [revokingId, setRevokingId] = useState(null) + const [compactTable, setCompactTable] = useState( + () => window.matchMedia( + `(max-width: ${designTokens.layout.breakpoint.mobile - 1}px)`, + ).matches, + ) + + useEffect(() => { + const query = window.matchMedia( + `(max-width: ${designTokens.layout.breakpoint.mobile - 1}px)`, + ) + const update = () => setCompactTable(query.matches) + query.addEventListener("change", update) + return () => query.removeEventListener("change", update) + }, []) const refresh = async () => { setLoading(true) @@ -57,14 +78,15 @@ export function UserApiKeys() { value: ns.name, })), ) - setError(null) + setLoadError(null) } catch (e) { - setError(String(e)) + setLoadError(errorMessage(e)) } finally { setLoading(false) } } + // biome-ignore lint/correctness/useExhaustiveDependencies: one-time page load useEffect(() => { refresh() }, []) @@ -82,45 +104,83 @@ export function UserApiKeys() { setSelectedScope([]) await refresh() } catch (e) { - setError(String(e)) + flash.push({ + type: "error", + header: "Failed to create API key", + content: errorMessage(e), + }) } finally { setCreating(false) } } const remove = async (id: string) => { + setRevokingId(id) try { await deleteUserKey(id) setConfirmRevoke(null) + flash.push({ + type: "success", + header: "API key revoked", + content: "Systems using this key can no longer authenticate.", + }) await refresh() } catch (e) { - setError(String(e)) + flash.push({ + type: "error", + header: "Failed to revoke API key", + content: errorMessage(e), + }) + } finally { + setRevokingId(null) } } return ( - - {error && ( - setError(null)}> - {error} - - )} - - Create a new API key}> -
+ } + > + + - Create key - + Create API key + } > - - + + Create key + + } + > + + setName(e.detail.value)} /> - - - + + +
-
r.name }, + {loadError ? ( + + Retry} + > + {loadError} + + + ) : ( +
+
( + + {r.name} + {compactTable ? {r.clientId} : null} + + ), + }, { id: "client_id", header: "Client ID", cell: r => {r.clientId} }, { id: "scope", @@ -161,13 +246,43 @@ export function UserApiKeys() { id: "actions", header: "", cell: r => ( - + setConfirmRevoke(r.id)} + > + Revoke + ), }, - ]} - empty={No API keys yet.} - header={
Your API keys
} - /> + ]} + empty={ + document.getElementById("api-key-name")?.focus()} + > + Create API key + + } + > + Create a key when a tool or workflow needs to access Cua. + + } + header={ +
+ API keys +
+ } + /> + + )} {/* Key-created modal -- shows credentials once */} {created && ( @@ -177,9 +292,9 @@ export function UserApiKeys() { onDismiss={() => setCreated(null)} footer={ - + } > @@ -225,13 +340,14 @@ export function UserApiKeys() { footer={ - - + } @@ -240,6 +356,7 @@ export function UserApiKeys() { access immediately. )} - + + ) } diff --git a/libs/fleet/src/sdk/billing.ts b/libs/fleet/src/sdk/billing.ts index 3ff3c386c6..78084ef46e 100644 --- a/libs/fleet/src/sdk/billing.ts +++ b/libs/fleet/src/sdk/billing.ts @@ -1,4 +1,8 @@ import { getToken } from "../auth/keycloak"; +import { + isLocalVisualPreview, + localVisualPreviewPath, +} from "../local-visual-preview"; export interface SavedCard { brand: string; @@ -27,9 +31,23 @@ async function billingRequest( } export const billingApi = { - summary: () => billingRequest("/api/billing/summary", "GET"), + summary: () => + isLocalVisualPreview() + ? Promise.resolve({ + payment_method_present: true, + card: { brand: "visa", last4: "4242", exp_month: 12, exp_year: 2030 }, + }) + : billingRequest("/api/billing/summary", "GET"), setup: () => - billingRequest<{ url: string }>("/api/billing/setup-session", "POST"), + isLocalVisualPreview() + ? Promise.resolve({ + url: localVisualPreviewPath("/settings?billing=setup-preview"), + }) + : billingRequest<{ url: string }>("/api/billing/setup-session", "POST"), portal: () => - billingRequest<{ url: string }>("/api/billing/portal-session", "POST"), + isLocalVisualPreview() + ? Promise.resolve({ + url: localVisualPreviewPath("/settings?billing=portal-preview"), + }) + : billingRequest<{ url: string }>("/api/billing/portal-session", "POST"), }; diff --git a/libs/fleet/src/sdk/chat.ts b/libs/fleet/src/sdk/chat.ts index 214fee60f1..8c4270119f 100644 --- a/libs/fleet/src/sdk/chat.ts +++ b/libs/fleet/src/sdk/chat.ts @@ -1,4 +1,11 @@ import { getToken } from "../auth/keycloak" +import { + createLocalVisualPreviewConversation, + getLocalVisualPreviewConversation, + isLocalVisualPreview, + listLocalVisualPreviewConversations, + streamLocalVisualPreviewTurn, +} from "../local-visual-preview" export type ChatRole = "user" | "assistant" | "tool" @@ -130,15 +137,22 @@ function parseAssistantMessage(value: unknown): ChatMessage { } } -export function createConversation(): Promise { +export async function createConversation(): Promise { + if (isLocalVisualPreview()) return createLocalVisualPreviewConversation() return chatJson("/api/chat/conversations", { method: "POST" }) } -export function listConversations(): Promise { +export async function listConversations(): Promise { + if (isLocalVisualPreview()) return listLocalVisualPreviewConversations() return chatJson("/api/chat/conversations") } -export function getConversation(id: string): Promise { +export async function getConversation(id: string): Promise { + if (isLocalVisualPreview()) { + const conversation = getLocalVisualPreviewConversation(id) + if (!conversation) throw new ChatApiError("Conversation not found", 404) + return conversation + } return chatJson(`/api/chat/conversations/${encodeURIComponent(id)}`) } @@ -148,6 +162,9 @@ export async function streamTurn( onDelta: (delta: string) => void, signal?: AbortSignal, ): Promise { + if (isLocalVisualPreview()) { + return streamLocalVisualPreviewTurn(conversationId, messages, onDelta) + } const response = await chatFetch( `/api/chat/conversations/${encodeURIComponent(conversationId)}/turns`, { method: "POST", body: JSON.stringify({ messages }), signal }, diff --git a/libs/fleet/src/sdk/claims.ts b/libs/fleet/src/sdk/claims.ts index 21233e648c..97c3885406 100644 --- a/libs/fleet/src/sdk/claims.ts +++ b/libs/fleet/src/sdk/claims.ts @@ -7,6 +7,7 @@ import { } from "./generated" import { withClient } from "./client" import type { Claim } from "./models" +import { isLocalVisualPreview } from "../local-visual-preview" export function buildClaimRequest(pool: Pool): CreateClaimRequest { return new CreateClaimRequestBuilder().pool(pool).build() @@ -38,6 +39,7 @@ async function findClaim( } export async function listClaims(namespace: string): Promise { + if (isLocalVisualPreview()) return [] return withClient(async client => (await client.listClaims(namespace)).map(claimModel), ) diff --git a/libs/fleet/src/sdk/client.ts b/libs/fleet/src/sdk/client.ts index 45057011d1..0a2dd233d5 100644 --- a/libs/fleet/src/sdk/client.ts +++ b/libs/fleet/src/sdk/client.ts @@ -8,6 +8,10 @@ import { const sdkInitialization = uniffiInitAsync() +export async function ensureSdkInitialized(): Promise { + await sdkInitialization +} + function baseUrl(): string { return window.location.origin } @@ -25,7 +29,7 @@ export function buildClientConfiguration(): CyclopsTokenProviderConfiguration { export async function withClient( operation: (client: CyclopsClient) => Promise, ): Promise { - await sdkInitialization + await ensureSdkInitialized() const token = await getToken() if (!token) throw new Error("Authentication token is unavailable") diff --git a/libs/fleet/src/sdk/githubTrustPolicies.ts b/libs/fleet/src/sdk/githubTrustPolicies.ts index 0d7c918867..24a111ad47 100644 --- a/libs/fleet/src/sdk/githubTrustPolicies.ts +++ b/libs/fleet/src/sdk/githubTrustPolicies.ts @@ -1,4 +1,5 @@ import { getToken } from "../auth/keycloak" +import { isLocalVisualPreview } from "../local-visual-preview" export interface Namespace { name: string @@ -32,6 +33,18 @@ export interface GitHubTrustPolicyInput { enabled: boolean } +let localVisualPreviewPolicies: GitHubTrustPolicy[] = [ + { + id: "preview-policy-1", + name: "Cloud CI", + repository: "trycua/cloud", + allowed_namespaces: ["preview"], + enabled: true, + created_at: "2026-08-16T20:20:00.000Z", + updated_at: "2026-08-16T20:20:00.000Z", + }, +] + async function request(path: string, init?: RequestInit): Promise { const token = await getToken() const response = await fetch(path, { @@ -51,23 +64,72 @@ async function request(path: string, init?: RequestInit): Promise { } export const githubTrustPoliciesApi = { - list: () => request("/api/github-trust-policies"), - create: (input: GitHubTrustPolicyInput) => - request("/api/github-trust-policies", { + list: async () => { + if (isLocalVisualPreview()) { + return { + policies: [...localVisualPreviewPolicies], + oidc: { + issuer: "https://token.actions.githubusercontent.com", + audience: "fleets", + }, + } + } + return request("/api/github-trust-policies") + }, + create: async (input: GitHubTrustPolicyInput) => { + if (isLocalVisualPreview()) { + const now = new Date().toISOString() + const policy: GitHubTrustPolicy = { + id: `preview-policy-${localVisualPreviewPolicies.length + 1}`, + ...input, + created_at: now, + updated_at: now, + } + localVisualPreviewPolicies = [...localVisualPreviewPolicies, policy] + return policy + } + return request("/api/github-trust-policies", { method: "POST", body: JSON.stringify(input), - }), - update: (id: string, input: Partial) => - request(`/api/github-trust-policies/${encodeURIComponent(id)}`, { + }) + }, + update: async (id: string, input: Partial) => { + if (isLocalVisualPreview()) { + const current = localVisualPreviewPolicies.find(policy => policy.id === id) + if (!current) throw new Error("Preview trust policy not found") + const policy = { ...current, ...input, updated_at: new Date().toISOString() } + localVisualPreviewPolicies = localVisualPreviewPolicies.map(item => + item.id === id ? policy : item, + ) + return policy + } + return request(`/api/github-trust-policies/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input), - }), - remove: (id: string) => - request(`/api/github-trust-policies/${encodeURIComponent(id)}`, { + }) + }, + remove: async (id: string) => { + if (isLocalVisualPreview()) { + localVisualPreviewPolicies = localVisualPreviewPolicies.filter( + policy => policy.id !== id, + ) + return + } + return request(`/api/github-trust-policies/${encodeURIComponent(id)}`, { method: "DELETE", - }), + }) + }, } export const namespacesApi = { - list: () => request("/api/namespaces"), + list: () => isLocalVisualPreview() + ? Promise.resolve([ + { + name: "preview", + status: "Active", + createdAt: "2026-08-16T20:20:00.000Z", + labels: null, + }, + ]) + : request("/api/namespaces"), } diff --git a/libs/fleet/src/sdk/pools.ts b/libs/fleet/src/sdk/pools.ts index 4a5c1f0bf5..0de62f161a 100644 --- a/libs/fleet/src/sdk/pools.ts +++ b/libs/fleet/src/sdk/pools.ts @@ -32,6 +32,11 @@ import type { PoolSummary, PoolTemplateConfig, } from "./models" +import { + getLocalVisualPreviewPool, + isLocalVisualPreview, + listLocalVisualPreviewPools, +} from "../local-visual-preview" function serviceProtocol(protocol: ServiceProtocol | undefined): string { return protocol === ServiceProtocol.Udp ? "UDP" : "TCP" @@ -87,10 +92,21 @@ async function listNamespacesWith(client: CyclopsClient): Promise { } export async function listNamespaces(): Promise { + if (isLocalVisualPreview()) { + return [ + { + name: "preview", + status: "Active", + createdAt: "2026-08-16T20:20:00.000Z", + labels: undefined, + }, + ] + } return withClient(listNamespacesWith) } export async function listPools(): Promise { + if (isLocalVisualPreview()) return listLocalVisualPreviewPools() return withClient(async client => { const namespaces = await listNamespacesWith(client) const pools = await Promise.all( @@ -123,6 +139,11 @@ async function getPoolWith( } export async function getPool(namespace: string, name: string): Promise { + if (isLocalVisualPreview()) { + const pool = getLocalVisualPreviewPool(namespace, name) + if (!pool) throw new Error(`Preview pool ${namespace}/${name} was not found`) + return pool + } return withClient(async client => { const resources = await getPoolWith(client, namespace, name) return poolData(resources.pool, resources.template) diff --git a/libs/fleet/src/sdk/userKeys.ts b/libs/fleet/src/sdk/userKeys.ts index 5c8f85580e..d3b287cd8f 100644 --- a/libs/fleet/src/sdk/userKeys.ts +++ b/libs/fleet/src/sdk/userKeys.ts @@ -5,9 +5,19 @@ import { type UserApiKey, } from "./generated" import { withClient } from "./client" +import { isLocalVisualPreview } from "../local-visual-preview" export type { NewUserApiKey, UserApiKey } from "./generated" +let localVisualPreviewKeys: UserApiKey[] = [ + { + id: "preview-key-1", + clientId: "cua_preview_automation", + name: "Preview automation", + scope: ["preview"], + }, +] + export function buildUserApiKeyRequest( name: string, scope?: string[], @@ -19,6 +29,7 @@ export function buildUserApiKeyRequest( } export async function listUserKeys(): Promise { + if (isLocalVisualPreview()) return [...localVisualPreviewKeys] return withClient(client => client.listUserApiKeys()) } @@ -26,9 +37,29 @@ export async function createUserKey( name: string, scope?: string[], ): Promise { + if (isLocalVisualPreview()) { + const key: UserApiKey = { + id: `preview-key-${localVisualPreviewKeys.length + 1}`, + clientId: `cua_preview_${name.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`, + name, + scope: scope ?? [], + } + localVisualPreviewKeys = [...localVisualPreviewKeys, key] + return { + clientId: key.clientId, + clientSecret: "cua_preview_secret_shown_once", + tokenUrl: "https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token", + name: key.name, + scope: key.scope, + } + } return withClient(client => client.createUserApiKey(buildUserApiKeyRequest(name, scope))) } export async function deleteUserKey(id: string): Promise { + if (isLocalVisualPreview()) { + localVisualPreviewKeys = localVisualPreviewKeys.filter(key => key.id !== id) + return + } await withClient(client => client.deleteUserApiKey(id)) } diff --git a/libs/fleet/src/shell.css b/libs/fleet/src/shell.css new file mode 100644 index 0000000000..eae4a194e3 --- /dev/null +++ b/libs/fleet/src/shell.css @@ -0,0 +1,306 @@ +html, +body, +#root, +.cua-shell { + min-height: 100%; + background: var(--cua-dashboard-background, #000000); +} + +.cua-shell { + color: var(--cua-dashboard-text); + font-family: var(--cua-dashboard-font-body); +} + +.cua-shell__topnav, +.cua-pagehead { + position: relative; + isolation: isolate; +} + +.cua-shell__topnav { + z-index: 1000; + overflow: visible; + background: var(--cua-dashboard-background); +} + +.cua-shell__topnav::before { + content: ""; + position: absolute; + z-index: -2; + inset: 0; + pointer-events: none; + background: var(--cua-dashboard-band); + mask-image: linear-gradient(180deg, #000000 0%, #000000 48%, transparent 100%); +} + +.cua-shell__topnav::after, +.cua-pagehead::after { + content: ""; + position: absolute; + z-index: -1; + inset: 0; + pointer-events: none; + background-image: var(--cua-dashboard-band-grain-image); + background-size: var(--cua-dashboard-band-grain-size); + opacity: var(--cua-dashboard-band-grain-opacity); +} + +.cua-pagehead::after { + inset-block: -24px; + inset-inline: calc(-1 * var(--cua-pagehead-bleed)); +} + +.cua-shell__topnav > * { + position: relative; + z-index: 1; + background: transparent; +} + +.cua-shell__nav { + font-size: 14px; +} + +.cua-pagehead { + box-sizing: border-box; + min-height: 96px; + padding: 18px 0 14px; + --cua-pagehead-bleed: var(--cua-layout-application-inline); +} + +.cua-pagehead__eyebrow { + margin: 0 0 10px; + color: var(--cua-color-text-muted); + font-family: var(--cua-dashboard-font-mono); + font-size: var(--cua-dashboard-eyebrow-size); + font-weight: 500; + letter-spacing: var(--cua-dashboard-eyebrow-tracking); + line-height: 1.4; + text-transform: uppercase; +} + +.cua-pagehead__title { + margin: 0; + color: var(--cua-dashboard-text); + font-family: var(--cua-dashboard-font-body); + font-size: var(--cua-font-size-page-title); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.2; +} + +.cua-pagehead__actions, +.cua-pagebody { + min-width: 0; +} + +.cua-pagehead__actions { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; +} + +.cua-selection-actions { + display: flex; + min-height: 48px; + padding: 8px 0; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--cua-color-text-body); +} + +.cua-page-table { + min-width: 0; + overflow-x: auto; + border-radius: var(--cua-radius-control); +} + +.cua-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.cua-pool-name { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#cua-dashboard-root .cua-dashboard-button button { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--cua-control-height-compact); + padding: var(--cua-action-padding-block) var(--cua-action-padding-inline); + border-radius: var(--cua-radius-control); + font-family: var(--cua-dashboard-font-body); + font-size: var(--cua-action-font-size); + font-weight: var(--cua-action-font-weight); + letter-spacing: var(--cua-action-letter-spacing); + line-height: var(--cua-action-line-height); +} + +#cua-dashboard-root .cua-dashboard-button--primary button:not(:disabled), +#cua-dashboard-root .cua-dashboard-button--primary button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--primary button:not(:disabled):active { + border-color: transparent; + background: var(--cua-action-primary-background); + box-shadow: none; + color: var(--cua-action-primary-foreground); +} + +#cua-dashboard-root .cua-dashboard-button--primary button:not(:disabled):hover { + filter: var(--cua-action-primary-hover-filter); +} + +#cua-dashboard-root .cua-dashboard-button--secondary button:not(:disabled), +#cua-dashboard-root .cua-dashboard-button--secondary button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--secondary button:not(:disabled):active, +#cua-dashboard-root .cua-dashboard-button--icon button:not(:disabled), +#cua-dashboard-root .cua-dashboard-button--icon button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--icon button:not(:disabled):active { + border-color: var(--cua-action-secondary-application-border); + background: var(--cua-action-secondary-application-background); + color: var(--cua-action-secondary-application-foreground); +} + +#cua-dashboard-root .cua-dashboard-button--secondary button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--icon button:not(:disabled):hover { + border-color: var(--cua-action-secondary-application-hover-border); + background: var(--cua-action-secondary-application-background); + color: var(--cua-action-secondary-application-hover-foreground); +} + +#cua-dashboard-root .cua-dashboard-button--icon button { + width: 40px; + padding-inline: 0; +} + +#cua-dashboard-root .cua-dashboard-button--danger button:not(:disabled), +#cua-dashboard-root .cua-dashboard-button--danger button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--danger button:not(:disabled):active { + border-color: var(--cua-action-danger-application-border); + background: var(--cua-action-danger-application-background); + color: var(--cua-action-danger-application-foreground); +} + +#cua-dashboard-root .cua-dashboard-button--danger button:not(:disabled):hover, +#cua-dashboard-root .cua-dashboard-button--danger button:not(:disabled):active { + border-color: var(--cua-action-danger-application-hover-border); + background: var(--cua-action-danger-application-hover-background); +} + +#cua-dashboard-root + .cua-dashboard-button.cua-dashboard-button--secondary + button:disabled, +#cua-dashboard-root + .cua-dashboard-button.cua-dashboard-button--secondary + button[aria-disabled="true"], +#cua-dashboard-root + .cua-dashboard-button.cua-dashboard-button--icon + button:disabled, +#cua-dashboard-root + .cua-dashboard-button.cua-dashboard-button--icon + button[aria-disabled="true"] { + border-color: var(--cua-action-secondary-application-border); + background: var(--cua-action-secondary-application-background); + color: var(--cua-action-secondary-application-foreground); + opacity: 0.55; +} + +/* Cloudscape paints its focus ring on a button::before layer with geometry + that does not follow Cua's control radius. Keep its focus behavior, but + replace that paint layer with the shared Cua focus tokens. */ +body#cua-dashboard-root[data-awsui-focus-visible="true"] + .cua-dashboard-button + button:focus::before { + content: none; +} + +#cua-dashboard-root .cua-dashboard-button button:focus-visible, +body#cua-dashboard-root[data-awsui-focus-visible="true"] + .cua-dashboard-button + button:focus-visible { + outline: var(--cua-focus-width) solid var(--cua-focus-color); + outline-offset: var(--cua-focus-offset); +} + +#cua-dashboard-root [aria-label="Open navigation"], +#cua-dashboard-root [aria-label="Close navigation"] { + /* Cloudscape's scoped trigger selector contains an ID specificity hack. */ + width: 40px !important; + height: 40px !important; + border: 1px solid var(--cua-action-secondary-application-border) !important; + border-radius: var(--cua-radius-control) !important; + background: var(--cua-action-secondary-application-background) !important; + color: var(--cua-action-secondary-application-foreground) !important; +} + +#cua-dashboard-root [aria-label="Open navigation"]:hover, +#cua-dashboard-root [aria-label="Close navigation"]:hover { + border-color: var(--cua-action-secondary-application-hover-border) !important; + color: var(--cua-action-secondary-application-hover-foreground) !important; +} + +.cua-pagehead h1 { + /* Cloudscape renders the counter beside the full-contrast inner title. */ + color: var(--cua-color-text-muted); +} + +@media (max-width: 1023px) { + #root .cua-shell main > div:has(> nav[aria-label="Main navigation"]) { + inset-block-start: 0; + block-size: 100dvh; + } +} + +@media (max-width: 687px) { + .cua-pagehead { + min-height: 96px; + padding-top: 16px; + } + + .cua-pagehead__title { + font-size: var(--cua-font-size-page-title-compact); + } + + .cua-pagehead { + --cua-pagehead-bleed: var(--cua-layout-application-inline-compact); + } + + .cua-pagehead__actions { + flex-wrap: nowrap; + } + + #cua-dashboard-root + .cua-pagehead__actions + .cua-dashboard-button:not(.cua-dashboard-button--icon) + button { + padding-inline: var(--cua-action-padding-inline-compact); + } +} + +@media (max-width: 374px) { + .cua-pagehead { + --cua-pagehead-bleed: var(--cua-layout-application-inline-tight); + } +} + +@media (forced-colors: active) { + .cua-shell__topnav::before, + .cua-shell__topnav::after, + .cua-pagehead::after { + display: none; + } +} diff --git a/libs/fleet/src/types/assets.d.ts b/libs/fleet/src/types/assets.d.ts new file mode 100644 index 0000000000..ea84a4a00a --- /dev/null +++ b/libs/fleet/src/types/assets.d.ts @@ -0,0 +1,11 @@ +/// + +declare module "*.svg" { + const url: string + export default url +} + +declare module "*.woff2" { + const url: string + export default url +} diff --git a/libs/fleet/tests/auth-redirects.test.ts b/libs/fleet/tests/auth-redirects.test.ts new file mode 100644 index 0000000000..6fe152bdfb --- /dev/null +++ b/libs/fleet/tests/auth-redirects.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + appLoginRedirectUri, + appLogoutRedirectUri, +} from "../src/auth/redirects.ts" + +test("login redirect preserves the current Run route and ordinary parameters", () => { + assert.equal( + appLoginRedirectUri("https://run.cua.ai/pools/demo?tab=activity#logs"), + "https://run.cua.ai/pools/demo?tab=activity#logs", + ) +}) + +test("login redirect strips a successful OIDC query callback", () => { + assert.equal( + appLoginRedirectUri( + "https://run.cua.ai/settings?tab=billing&code=one-time&state=opaque&session_state=opaque&iss=https%3A%2F%2Fauth.cua.ai%2Frealms%2Fcyclops-cs", + ), + "https://run.cua.ai/settings?tab=billing", + ) +}) + +test("login redirect strips a rejected OIDC fragment callback", () => { + assert.equal( + appLoginRedirectUri( + "https://run.cua.ai/pools#state=opaque&error=login_required&error_description=expired", + ), + "https://run.cua.ai/pools", + ) +}) + +test("login redirect does not remove unrelated application errors", () => { + assert.equal( + appLoginRedirectUri("https://run.cua.ai/pools?error=capacity"), + "https://run.cua.ai/pools?error=capacity", + ) +}) + +test("logout redirect is pinned to the same-origin Run root", () => { + assert.equal( + appLogoutRedirectUri("https://run.cua.ai/settings?tab=billing"), + "https://run.cua.ai/", + ) + assert.equal( + appLogoutRedirectUri("http://localhost:9090/pools/demo"), + "http://localhost:9090/", + ) +}) diff --git a/libs/fleet/tests/design-package-boundary.test.mjs b/libs/fleet/tests/design-package-boundary.test.mjs new file mode 100644 index 0000000000..0991678fe7 --- /dev/null +++ b/libs/fleet/tests/design-package-boundary.test.mjs @@ -0,0 +1,148 @@ +import assert from "node:assert/strict" +import { readdir, readFile } from "node:fs/promises" +import test from "node:test" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const cyclopsRoot = path.resolve(__dirname, "..") +const repositoryRoot = path.resolve(cyclopsRoot, "..") + +async function readCssTree(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + return ( + await Promise.all( + entries.map(entry => { + const entryPath = path.join(directory, entry.name) + if (entry.isDirectory()) return readCssTree(entryPath) + if (entry.isFile() && entry.name.endsWith(".css")) { + return readFile(entryPath, "utf8") + } + return [] + }), + ) + ).flat() +} + +test("dashboard consumes the runtime-neutral design package", async () => { + const [packageJson, entrypoint] = await Promise.all([ + readFile(path.join(cyclopsRoot, "package.json"), "utf8").then(JSON.parse), + readFile(path.join(cyclopsRoot, "src/main.tsx"), "utf8"), + ]) + + assert.equal( + packageJson.dependencies["@cua/design"], + "file:../packages/cua-design", + ) + assert.equal(packageJson.dependencies["@cua/mesh"], undefined) + assert.equal(packageJson.dependencies["@paper-design/shaders"], undefined) + + const designImport = entrypoint.indexOf( + 'import "@cua/design/dashboard.css"', + ) + const cloudscapeImport = entrypoint.indexOf( + 'import "@cloudscape-design/global-styles/index.css"', + ) + assert.ok(designImport >= 0, "expected the shared dashboard CSS import") + assert.ok( + designImport < cloudscapeImport, + "shared foundations must load before Cloudscape globals", + ) +}) + +test("frontend image builds from the repository root", async () => { + const [dockerfile, workflow] = await Promise.all([ + readFile(path.join(cyclopsRoot, "Dockerfile"), "utf8"), + readFile( + path.join(repositoryRoot, ".github/workflows/build-cyclops-cs.yml"), + "utf8", + ), + ]) + + assert.match(dockerfile, /COPY packages\/cua-design\/package\.json/) + assert.doesNotMatch(dockerfile, /packages\/cua-mesh/) + assert.match(dockerfile, /COPY cyclops-cs\/package\.json/) + assert.match( + workflow, + /- image: cyclops-cs[\s\S]*?context: \.[\s\S]*?dockerfile: cyclops-cs\/Dockerfile/, + ) + assert.match(workflow, /- 'packages\/cua-design\/\*\*'/) +}) + +test("dashboard activates the shared theme through supported boundaries", async () => { + const [entrypoint, shell, shellStyles, visualPreview] = await Promise.all([ + readFile(path.join(cyclopsRoot, "src/main.tsx"), "utf8"), + readFile(path.join(cyclopsRoot, "src/App.tsx"), "utf8"), + readFile(path.join(cyclopsRoot, "src/shell.css"), "utf8"), + readFile(path.join(cyclopsRoot, "src/local-visual-preview.ts"), "utf8"), + ]) + const allStyles = (await readCssTree(path.join(cyclopsRoot, "src"))).join( + "\n", + ) + + assert.match(entrypoint, /@cloudscape-design\/components\/theming/) + assert.match(entrypoint, /applyTheme\(\{/) + assert.match(shell, /className="cua-dashboard-theme cua-shell"/) + assert.match(shell, /className="cua-shell__topnav"/) + assert.match(shell, /id="cua-shell-topnav"/) + assert.match(shell, /headerSelector="#cua-shell-topnav"/) + assert.match(shell, /navigationToggle: "Open navigation"/) + assert.match(shell, /navigationClose: "Close navigation"/) + assert.match( + shell, + /header=\{mobile \? undefined : \{ href: "#\/pools", text: "Cua" \}\}/, + ) + assert.match(entrypoint, /document\.body\.id = "cua-dashboard-root"/) + assert.doesNotMatch(shellStyles, /\.cua-pagehead__mesh/) + assert.match(shellStyles, /@media \(forced-colors: active\)/) + assert.doesNotMatch(shellStyles, /h1 > span:last-child/) + assert.doesNotMatch(allStyles, /--awsui-/) + assert.doesNotMatch(allStyles, /\.awsui[_-]/) + assert.doesNotMatch(allStyles, /--space-[\w-]+-[a-z0-9]{6}/) + assert.doesNotMatch(allStyles, /var\(--color-/) + assert.doesNotMatch( + shellStyles, + /linear-gradient\(135deg, #f0f8ff 0%, #9fd7ff 58%, #5f86b4 100%\)/, + ) + assert.doesNotMatch(shellStyles, /color: #07131e/) + assert.doesNotMatch(shellStyles, /min-height: 40px/) + assert.match(visualPreview, /import\.meta\.env\.DEV/) + assert.match(visualPreview, /VITE_CUA_LOCAL_VISUAL_PREVIEW === "true"/) + assert.match( + visualPreview, + /VITE_CUA_REVIEW_VISUAL_PREVIEW === "true"/, + ) + assert.match( + visualPreview, + /\^cyclops-cs-pr-\\d\+\\\.tail204509\\\.ts\\\.net\$/, + ) +}) + +test("account preview data stays behind the fail-closed visual-preview gate", async () => { + const files = await Promise.all( + [ + "src/sdk/userKeys.ts", + "src/sdk/githubTrustPolicies.ts", + "src/sdk/billing.ts", + "src/components/FeatureFlagContext.tsx", + ].map(relativePath => + readFile(path.join(cyclopsRoot, relativePath), "utf8"), + ), + ) + + for (const source of files.slice(0, 3)) { + assert.match(source, /isLocalVisualPreview\(\)/) + assert.match( + source, + /if \(isLocalVisualPreview\(\)\)|isLocalVisualPreview\(\)\s*\?/, + ) + assert.doesNotMatch(source, /if \(!isLocalVisualPreview\(\)\)/) + } + assert.match(files[3], /const visualPreview = isLocalVisualPreview\(\)/) + assert.match(files[3], /visualPreview\s*\?/) + assert.doesNotMatch(files[3], /!visualPreview/) + assert.match(files[0], /withClient\(client => client\.listUserApiKeys\(\)\)/) + assert.match(files[1], /request/) + assert.match(files[2], /billingRequest/) +}) diff --git a/libs/fleet/vite.config.ts b/libs/fleet/vite.config.ts index 0f8049b169..5bb5899969 100644 --- a/libs/fleet/vite.config.ts +++ b/libs/fleet/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ proxy: { // Upstream cyclops-ctrl is retired — every /api route is served by // the cyclops-cs backend behind the deployed ingress. - "/api": { + "^/api(?:/|$)": { target: ORCH_API, changeOrigin: true, secure: true, From ef5912c186e0571004cbc0feeb5fb00a364cb70d Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 17:42:46 +0000 Subject: [PATCH 058/117] feat(cyclops-sdk): raise PoolAccessDenied for 403s on pool-namespace writes (#7013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cyclops-sdk): raise PoolAccessDenied for 403s on pool-namespace writes Pool names double as namespaces and are globally unique across accounts, so a 403 from a pool, template, or namespace write usually means the name is owned by someone else — but the gateway's opaque 'k8s request is not allowed' body gave callers nothing to act on. Add SdkError::PoolAccessDenied carrying operation, namespace, status, and body, with a message that says the name may be taken, to try a new pool name, and to contact support on Discord (https://discord.gg/mVnXXpdE85) if that does not work. Map 403s into it on create/update/delete pool, create/update/delete template, and the namespace create that backs create_pool. Reads keep plain Status errors so reconcile flows still treat 403 like not-visible-create-instead. Regenerate the Python, Kotlin, Swift, and Ruby bindings via generate-sdk-bindings.sh and the browser TypeScript modules via build-browser-sdk-binding.sh; the browser snapshot also catches up pre-existing drift (namespace client methods, nestedVirtualization). The go-uniffi and ts-uniffi compatibility snapshots remain on their separate regeneration pipelines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012kbiBCs8Wo3LtXCTEvV7dF * test(cyclops-sdk): cover remaining 403 pool-access-denied write paths Add direct 403 mapping tests for delete_pool, the delete_pool namespace cleanup step, create_template, and delete_template. These paths were mapped by SdkError::deny_pool_access but only exercised indirectly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012kbiBCs8Wo3LtXCTEvV7dF --------- Co-authored-by: Claude Fable 5 CloudCyclopsCs-RevId: 7318f2e07fc1901a56f0a60335c39d55d5965e29 --- .../kotlin/ai/cua/cyclops/sdk/fleet_sdk.kt | 36 +++ .../sdk-bindings/python/fleet_sdk/_sdk.py | 36 +++ .../sdk-bindings/ruby/cyclops_sdk/sdk.rb | 24 ++ .../fleet/sdk-bindings/swift/CyclopsSdk.swift | 16 ++ .../ts/cyclops_sdk_schema.ts | 24 ++ .../ts-uniffi-browser/ts/fleet_sdk.ts | 228 +++++++++++++++++- libs/fleet/sdk/src/error.rs | 31 +++ libs/fleet/sdk/src/pools.rs | 10 +- libs/fleet/sdk/src/templates.rs | 3 + libs/fleet/sdk/tests/pool_flow.rs | 138 +++++++++++ libs/fleet/sdk/tests/template_flow.rs | 77 +++++- 11 files changed, 618 insertions(+), 5 deletions(-) diff --git a/libs/fleet/sdk-bindings/kotlin/ai/cua/cyclops/sdk/fleet_sdk.kt b/libs/fleet/sdk-bindings/kotlin/ai/cua/cyclops/sdk/fleet_sdk.kt index 7a55af2a76..19088399ad 100644 --- a/libs/fleet/sdk-bindings/kotlin/ai/cua/cyclops/sdk/fleet_sdk.kt +++ b/libs/fleet/sdk-bindings/kotlin/ai/cua/cyclops/sdk/fleet_sdk.kt @@ -7052,6 +7052,20 @@ sealed class SdkException: kotlin.Exception() { get() = "" } + class PoolAccessDenied( + + val `operation`: kotlin.String, + + val `namespace`: kotlin.String, + + val `status`: kotlin.UShort, + + val `body`: kotlin.String + ) : SdkException() { + override val message + get() = "operation=${ `operation` }, namespace=${ `namespace` }, status=${ `status` }, body=${ `body` }" + } + @@ -7105,6 +7119,12 @@ public object FfiConverterTypeSdkError : FfiConverterRustBuffer { FfiConverterString.read(buf), ) 10 -> SdkException.ClaimTimeout() + 11 -> SdkException.PoolAccessDenied( + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterUShort.read(buf), + FfiConverterString.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -7166,6 +7186,14 @@ public object FfiConverterTypeSdkError : FfiConverterRustBuffer { // Add the size for the Int that specifies the variant plus the size needed for all fields 4UL ) + is SdkException.PoolAccessDenied -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterString.allocationSize(value.`operation`) + + FfiConverterString.allocationSize(value.`namespace`) + + FfiConverterUShort.allocationSize(value.`status`) + + FfiConverterString.allocationSize(value.`body`) + ) } } @@ -7226,6 +7254,14 @@ public object FfiConverterTypeSdkError : FfiConverterRustBuffer { buf.putInt(10) Unit } + is SdkException.PoolAccessDenied -> { + buf.putInt(11) + FfiConverterString.write(value.`operation`, buf) + FfiConverterString.write(value.`namespace`, buf) + FfiConverterUShort.write(value.`status`, buf) + FfiConverterString.write(value.`body`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } diff --git a/libs/fleet/sdk-bindings/python/fleet_sdk/_sdk.py b/libs/fleet/sdk-bindings/python/fleet_sdk/_sdk.py index 4b9836901c..72692f9857 100644 --- a/libs/fleet/sdk-bindings/python/fleet_sdk/_sdk.py +++ b/libs/fleet/sdk-bindings/python/fleet_sdk/_sdk.py @@ -3350,6 +3350,23 @@ def __init__(self): def __repr__(self): return "SdkError.ClaimTimeout({})".format(str(self)) _UniffiTempSdkError.ClaimTimeout = ClaimTimeout # type: ignore + class PoolAccessDenied(_UniffiTempSdkError): + + def __init__(self, operation, namespace, status, body): + super().__init__(", ".join([ + "operation={!r}".format(operation), + "namespace={!r}".format(namespace), + "status={!r}".format(status), + "body={!r}".format(body), + ])) + self.operation = operation + self.namespace = namespace + self.status = status + self.body = body + + def __repr__(self): + return "SdkError.PoolAccessDenied({})".format(str(self)) + _UniffiTempSdkError.PoolAccessDenied = PoolAccessDenied # type: ignore SdkError = _UniffiTempSdkError # type: ignore del _UniffiTempSdkError @@ -3404,6 +3421,13 @@ def read(buf): if variant == 10: return SdkError.ClaimTimeout( ) + if variant == 11: + return SdkError.PoolAccessDenied( + _UniffiFfiConverterString.read(buf), + _UniffiFfiConverterString.read(buf), + _UniffiFfiConverterUInt16.read(buf), + _UniffiFfiConverterString.read(buf), + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -3443,6 +3467,12 @@ def check_lower(value): return if isinstance(value, SdkError.ClaimTimeout): return + if isinstance(value, SdkError.PoolAccessDenied): + _UniffiFfiConverterString.check_lower(value.operation) + _UniffiFfiConverterString.check_lower(value.namespace) + _UniffiFfiConverterUInt16.check_lower(value.status) + _UniffiFfiConverterString.check_lower(value.body) + return @staticmethod def write(value, buf): @@ -3481,6 +3511,12 @@ def write(value, buf): _UniffiFfiConverterString.write(value.status, buf) if isinstance(value, SdkError.ClaimTimeout): buf.write_i32(10) + if isinstance(value, SdkError.PoolAccessDenied): + buf.write_i32(11) + _UniffiFfiConverterString.write(value.operation, buf) + _UniffiFfiConverterString.write(value.namespace, buf) + _UniffiFfiConverterUInt16.write(value.status, buf) + _UniffiFfiConverterString.write(value.body, buf) class _UniffiFfiConverterBoolean: @classmethod diff --git a/libs/fleet/sdk-bindings/ruby/cyclops_sdk/sdk.rb b/libs/fleet/sdk-bindings/ruby/cyclops_sdk/sdk.rb index 8a565a5494..f141e0e157 100644 --- a/libs/fleet/sdk-bindings/ruby/cyclops_sdk/sdk.rb +++ b/libs/fleet/sdk-bindings/ruby/cyclops_sdk/sdk.rb @@ -1331,6 +1331,14 @@ def readTypeSdkError if variant == 10 return SdkError::ClaimTimeout.new end + if variant == 11 + return SdkError::PoolAccessDenied.new( + readString(), + readString(), + readU16(), + readString() + ) + end raise InternalError, 'Unexpected variant tag for TypeSdkError' end @@ -2267,6 +2275,22 @@ def to_s "#{self.class.name}()" end end + class PoolAccessDenied < StandardError + def initialize(operation, namespace, status, body) + @operation = operation + @namespace = namespace + @status = status + @body = body + super() + end + + attr_reader :operation, :namespace, :status, :body + + + def to_s + "#{self.class.name}(operation=#{@operation.inspect}, namespace=#{@namespace.inspect}, status=#{@status.inspect}, body=#{@body.inspect})" + end + end end diff --git a/libs/fleet/sdk-bindings/swift/CyclopsSdk.swift b/libs/fleet/sdk-bindings/swift/CyclopsSdk.swift index fdab5d5772..12dc32f572 100644 --- a/libs/fleet/sdk-bindings/swift/CyclopsSdk.swift +++ b/libs/fleet/sdk-bindings/swift/CyclopsSdk.swift @@ -4232,6 +4232,8 @@ public enum SdkError: Swift.Error, Equatable, Hashable, Foundation.LocalizedErro case ClaimFailed(phase: String, status: String ) case ClaimTimeout + case PoolAccessDenied(operation: String, namespace: String, status: UInt16, body: String + ) @@ -4295,6 +4297,12 @@ public struct FfiConverterTypeSdkError: FfiConverterRustBuffer { status: try FfiConverterString.read(from: &buf) ) case 10: return .ClaimTimeout + case 11: return .PoolAccessDenied( + operation: try FfiConverterString.read(from: &buf), + namespace: try FfiConverterString.read(from: &buf), + status: try FfiConverterUInt16.read(from: &buf), + body: try FfiConverterString.read(from: &buf) + ) default: throw UniffiInternalError.unexpectedEnumCase } @@ -4361,6 +4369,14 @@ public struct FfiConverterTypeSdkError: FfiConverterRustBuffer { case .ClaimTimeout: writeInt(&buf, Int32(10)) + + case let .PoolAccessDenied(operation,namespace,status,body): + writeInt(&buf, Int32(11)) + FfiConverterString.write(operation, into: &buf) + FfiConverterString.write(namespace, into: &buf) + FfiConverterUInt16.write(status, into: &buf) + FfiConverterString.write(body, into: &buf) + } } } diff --git a/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/cyclops_sdk_schema.ts b/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/cyclops_sdk_schema.ts index 2a5c3608e9..fe7a8e7680 100644 --- a/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/cyclops_sdk_schema.ts +++ b/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/cyclops_sdk_schema.ts @@ -2188,6 +2188,7 @@ export interface VmTemplateBuilderLike { imagePullPolicy(value: ImagePullPolicy): VmTemplateBuilderLike; imagePullSecret(value: string): VmTemplateBuilderLike; memory(value: string): VmTemplateBuilderLike; + nestedVirtualization(value: boolean): VmTemplateBuilderLike; nodeSelector(value: Map): VmTemplateBuilderLike; oidc(value: OidcConfig): VmTemplateBuilderLike; probes(value: PreservedJsonLike): VmTemplateBuilderLike; @@ -2360,6 +2361,21 @@ export class VmTemplateBuilder ); } + nestedVirtualization(value: boolean): VmTemplateBuilderLike { + return FfiConverterTypeVmTemplateBuilder.lift( + uniffiCaller.rustCall( + /*caller:*/ (callStatus) => { + return nativeModule().ubrn_uniffi_cyclops_sdk_schema_fn_method_vmtemplatebuilder_nested_virtualization( + uniffiTypeVmTemplateBuilderObjectFactory.clonePointer(this), + FfiConverterBool.lower(value, nativeModule().rustbuffer_alloc), + callStatus, + ); + }, + /*liftString:*/ FfiConverterString.lift.bind(FfiConverterString), + ), + ); + } + nodeSelector(value: Map): VmTemplateBuilderLike { return FfiConverterTypeVmTemplateBuilder.lift( uniffiCaller.rustCall( @@ -3122,6 +3138,14 @@ function uniffiEnsureInitialized() { "uniffi_cyclops_sdk_schema_checksum_method_vmtemplatebuilder_memory", ); } + if ( + nativeModule().ubrn_uniffi_cyclops_sdk_schema_checksum_method_vmtemplatebuilder_nested_virtualization() !== + 23834 + ) { + throw new UniffiInternalError.ApiChecksumMismatch( + "uniffi_cyclops_sdk_schema_checksum_method_vmtemplatebuilder_nested_virtualization", + ); + } if ( nativeModule().ubrn_uniffi_cyclops_sdk_schema_checksum_method_vmtemplatebuilder_node_selector() !== 45280 diff --git a/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/fleet_sdk.ts b/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/fleet_sdk.ts index 44cd92067d..a26b82de92 100644 --- a/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/fleet_sdk.ts +++ b/libs/fleet/sdk-bindings/ts-uniffi-browser/ts/fleet_sdk.ts @@ -1459,6 +1459,7 @@ export enum SdkError_Tags { InvalidServicePath = "InvalidServicePath", ClaimFailed = "ClaimFailed", ClaimTimeout = "ClaimTimeout", + PoolAccessDenied = "PoolAccessDenied", } export const SdkError = (() => { type Configuration__interface = { @@ -1816,6 +1817,69 @@ export const SdkError = (() => { } } + type PoolAccessDenied__interface = { + tag: SdkError_Tags.PoolAccessDenied; + inner: Readonly<{ + operation: string; + namespace: string; + status: number; + body: string; + }>; + }; + class PoolAccessDenied_ + extends UniffiError + implements PoolAccessDenied__interface + { + /** + * @private + * This field is private and should not be used, use `tag` instead. + */ + readonly [uniffiTypeNameSymbol] = "SdkError"; + readonly tag = SdkError_Tags.PoolAccessDenied; + readonly inner: Readonly<{ + operation: string; + namespace: string; + status: number; + body: string; + }>; + constructor(inner: { + operation: string; + namespace: string; + status: number; + body: string; + }) { + super("SdkError", "PoolAccessDenied"); + + this.inner = Object.freeze(inner); + } + static new(inner: { + operation: string; + namespace: string; + status: number; + body: string; + }): PoolAccessDenied_ { + return new PoolAccessDenied_(inner); + } + + static instanceOf(obj: any): obj is PoolAccessDenied_ { + return obj.tag === SdkError_Tags.PoolAccessDenied; + } + static hasInner(obj: any): obj is PoolAccessDenied_ { + return PoolAccessDenied_.instanceOf(obj); + } + + static getInner( + obj: PoolAccessDenied_, + ): Readonly<{ + operation: string; + namespace: string; + status: number; + body: string; + }> { + return obj.inner; + } + } + function instanceOf(obj: any): obj is SdkError { return obj[uniffiTypeNameSymbol] === "SdkError"; } @@ -1832,6 +1896,7 @@ export const SdkError = (() => { InvalidServicePath: InvalidServicePath_, ClaimFailed: ClaimFailed_, ClaimTimeout: ClaimTimeout_, + PoolAccessDenied: PoolAccessDenied_, }); })(); export type SdkError = InstanceType< @@ -1845,7 +1910,8 @@ export type SdkError = InstanceType< | "UnknownService" | "InvalidServicePath" | "ClaimFailed" - | "ClaimTimeout"] + | "ClaimTimeout" + | "PoolAccessDenied"] >; // FfiConverter for enum SdkError @@ -1895,6 +1961,13 @@ const FfiConverterTypeSdkError = (() => { }); case 10: return new SdkError.ClaimTimeout(); + case 11: + return new SdkError.PoolAccessDenied({ + operation: FfiConverterString.read(from), + namespace: FfiConverterString.read(from), + status: FfiConverterUInt16.read(from), + body: FfiConverterString.read(from), + }); default: throw new UniffiInternalError.UnexpectedEnumCase(); } @@ -1965,6 +2038,15 @@ const FfiConverterTypeSdkError = (() => { ordinalConverter.write(10, into); return; } + case SdkError_Tags.PoolAccessDenied: { + ordinalConverter.write(11, into); + const inner = value.inner; + FfiConverterString.write(inner.operation, into); + FfiConverterString.write(inner.namespace, into); + FfiConverterUInt16.write(inner.status, into); + FfiConverterString.write(inner.body, into); + return; + } default: // Throwing from here means that SdkError_Tags hasn't matched an ordinal. throw new UniffiInternalError.UnexpectedEnumCase(); @@ -2035,6 +2117,15 @@ const FfiConverterTypeSdkError = (() => { case SdkError_Tags.ClaimTimeout: { return ordinalConverter.allocationSize(10); } + case SdkError_Tags.PoolAccessDenied: { + const inner = value.inner; + let size = ordinalConverter.allocationSize(11); + size += FfiConverterString.allocationSize(inner.operation); + size += FfiConverterString.allocationSize(inner.namespace); + size += FfiConverterUInt16.allocationSize(inner.status); + size += FfiConverterString.allocationSize(inner.body); + return size; + } default: throw new UniffiInternalError.UnexpectedEnumCase(); } @@ -3070,6 +3161,10 @@ export interface CyclopsClientLike { request: CreateClaimRequest, asyncOpts_?: { signal: AbortSignal }, ) /*throws*/ : Promise; + createNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ) /*throws*/ : Promise; createPool( request: CreatePoolRequest, asyncOpts_?: { signal: AbortSignal }, @@ -3086,6 +3181,10 @@ export interface CyclopsClientLike { claim: Claim, asyncOpts_?: { signal: AbortSignal }, ) /*throws*/ : Promise; + deleteNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ) /*throws*/ : Promise; deletePool( pool: Pool, asyncOpts_?: { signal: AbortSignal }, @@ -3102,6 +3201,10 @@ export interface CyclopsClientLike { claim: Claim, asyncOpts_?: { signal: AbortSignal }, ) /*throws*/ : Promise; + getNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ) /*throws*/ : Promise; getPool( name: string, asyncOpts_?: { signal: AbortSignal }, @@ -3421,6 +3524,42 @@ export class CyclopsClient ); } + async createNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ): Promise /*throws*/ { + return await uniffiRustCallAsync( + /*rustCaller:*/ uniffiCaller, + /*rustFutureFunc:*/ () => { + return nativeModule().ubrn_uniffi_cyclops_sdk_fn_method_cyclopsclient_create_namespace( + uniffiTypeCyclopsClientObjectFactory.clonePointer(this), + FfiConverterString.lower(name, nativeModule().rustbuffer_alloc), + ); + }, + /*pollFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_poll_rust_buffer, + /*cancelFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_cancel_rust_buffer, + /*completeFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_complete_rust_buffer, + /*freeFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_free_rust_buffer, + // Async returns always go through the JS-side converter: the + // FFI symbol returns the future handle (u64), and the user-level + // RustBuffer comes back via the shared `rust_future_complete_*` + // export. The bytes the runtime hands back must be deserialized + // here using the per-callable return-type converter. + /*liftFunc:*/ FfiConverterTypeNamespace.lift.bind( + FfiConverterTypeNamespace, + ), + /*liftString:*/ FfiConverterString.lift.bind(FfiConverterString), + /*asyncOpts:*/ asyncOpts_, + /*errorHandler:*/ FfiConverterTypeSdkError.lift.bind( + FfiConverterTypeSdkError, + ), + ); + } + async createPool( request: CreatePoolRequest, asyncOpts_?: { signal: AbortSignal }, @@ -3563,6 +3702,33 @@ export class CyclopsClient ); } + async deleteNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ): Promise /*throws*/ { + return await uniffiRustCallAsync( + /*rustCaller:*/ uniffiCaller, + /*rustFutureFunc:*/ () => { + return nativeModule().ubrn_uniffi_cyclops_sdk_fn_method_cyclopsclient_delete_namespace( + uniffiTypeCyclopsClientObjectFactory.clonePointer(this), + FfiConverterString.lower(name, nativeModule().rustbuffer_alloc), + ); + }, + /*pollFunc:*/ nativeModule().ubrn_ffi_cyclops_sdk_rust_future_poll_void, + /*cancelFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_cancel_void, + /*completeFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_complete_void, + /*freeFunc:*/ nativeModule().ubrn_ffi_cyclops_sdk_rust_future_free_void, + /*liftFunc:*/ (_v) => {}, + /*liftString:*/ FfiConverterString.lift.bind(FfiConverterString), + /*asyncOpts:*/ asyncOpts_, + /*errorHandler:*/ FfiConverterTypeSdkError.lift.bind( + FfiConverterTypeSdkError, + ), + ); + } + async deletePool( pool: Pool, asyncOpts_?: { signal: AbortSignal }, @@ -3681,6 +3847,42 @@ export class CyclopsClient ); } + async getNamespace( + name: string, + asyncOpts_?: { signal: AbortSignal }, + ): Promise /*throws*/ { + return await uniffiRustCallAsync( + /*rustCaller:*/ uniffiCaller, + /*rustFutureFunc:*/ () => { + return nativeModule().ubrn_uniffi_cyclops_sdk_fn_method_cyclopsclient_get_namespace( + uniffiTypeCyclopsClientObjectFactory.clonePointer(this), + FfiConverterString.lower(name, nativeModule().rustbuffer_alloc), + ); + }, + /*pollFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_poll_rust_buffer, + /*cancelFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_cancel_rust_buffer, + /*completeFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_complete_rust_buffer, + /*freeFunc:*/ nativeModule() + .ubrn_ffi_cyclops_sdk_rust_future_free_rust_buffer, + // Async returns always go through the JS-side converter: the + // FFI symbol returns the future handle (u64), and the user-level + // RustBuffer comes back via the shared `rust_future_complete_*` + // export. The bytes the runtime hands back must be deserialized + // here using the per-callable return-type converter. + /*liftFunc:*/ FfiConverterTypeNamespace.lift.bind( + FfiConverterTypeNamespace, + ), + /*liftString:*/ FfiConverterString.lift.bind(FfiConverterString), + /*asyncOpts:*/ asyncOpts_, + /*errorHandler:*/ FfiConverterTypeSdkError.lift.bind( + FfiConverterTypeSdkError, + ), + ); + } + async getPool( name: string, asyncOpts_?: { signal: AbortSignal }, @@ -5323,6 +5525,14 @@ function uniffiEnsureInitialized() { "uniffi_cyclops_sdk_checksum_method_cyclopsclient_create_claim", ); } + if ( + nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_create_namespace() !== + 38049 + ) { + throw new UniffiInternalError.ApiChecksumMismatch( + "uniffi_cyclops_sdk_checksum_method_cyclopsclient_create_namespace", + ); + } if ( nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_create_pool() !== 48557 @@ -5355,6 +5565,14 @@ function uniffiEnsureInitialized() { "uniffi_cyclops_sdk_checksum_method_cyclopsclient_delete_claim", ); } + if ( + nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_delete_namespace() !== + 4545 + ) { + throw new UniffiInternalError.ApiChecksumMismatch( + "uniffi_cyclops_sdk_checksum_method_cyclopsclient_delete_namespace", + ); + } if ( nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_delete_pool() !== 31235 @@ -5387,6 +5605,14 @@ function uniffiEnsureInitialized() { "uniffi_cyclops_sdk_checksum_method_cyclopsclient_get_claim", ); } + if ( + nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_get_namespace() !== + 184 + ) { + throw new UniffiInternalError.ApiChecksumMismatch( + "uniffi_cyclops_sdk_checksum_method_cyclopsclient_get_namespace", + ); + } if ( nativeModule().ubrn_uniffi_cyclops_sdk_checksum_method_cyclopsclient_get_pool() !== 49450 diff --git a/libs/fleet/sdk/src/error.rs b/libs/fleet/sdk/src/error.rs index e645174435..08ddde56e5 100644 --- a/libs/fleet/sdk/src/error.rs +++ b/libs/fleet/sdk/src/error.rs @@ -47,6 +47,18 @@ pub enum SdkError { ClaimFailed { phase: String, status: String }, #[error("claim did not bind before the polling limit")] ClaimTimeout, + #[error( + "Fleet denied {operation} on pool namespace '{namespace}' (HTTP {status}: {body}). \ + Pool names are globally unique across accounts, so this name may already be taken — \ + try a new pool name. If that does not work, contact support on Discord: \ + https://discord.gg/mVnXXpdE85" + )] + PoolAccessDenied { + operation: String, + namespace: String, + status: u16, + body: String, + }, } impl SdkError { @@ -57,6 +69,25 @@ impl SdkError { body: bounded_body(body), } } + + /// Reinterpret an HTTP 403 from a pool-namespace write as a pool access + /// denial. Reads are left as plain `Status` errors so reconcile flows can + /// keep treating 403 like "not visible, try to create". + pub(crate) fn deny_pool_access(namespace: &str, error: SdkError) -> SdkError { + match error { + SdkError::Status { + operation, + status: status @ 403, + body, + } => SdkError::PoolAccessDenied { + operation, + namespace: namespace.into(), + status, + body, + }, + other => other, + } + } } pub fn bounded_body(body: &[u8]) -> String { diff --git a/libs/fleet/sdk/src/pools.rs b/libs/fleet/sdk/src/pools.rs index a9c640c74b..bdc1d8ff4d 100644 --- a/libs/fleet/sdk/src/pools.rs +++ b/libs/fleet/sdk/src/pools.rs @@ -44,7 +44,8 @@ impl CyclopsClient { .await; let namespace_created = matches!( self.create_namespace_if_missing(&pool.metadata.namespace) - .await?, + .await + .map_err(|error| SdkError::deny_pool_access(&pool.metadata.namespace, error))?, NamespaceOwnership::Created ); let result = self @@ -63,7 +64,7 @@ impl CyclopsClient { .delete_namespace(pool.metadata.namespace.clone()) .await; } - Err(error) + Err(SdkError::deny_pool_access(&pool.metadata.namespace, error)) } } } @@ -111,6 +112,7 @@ impl CyclopsClient { &[200], ) .await + .map_err(|error| SdkError::deny_pool_access(&pool.metadata.namespace, error)) } pub async fn delete_pool(self: Arc, pool: Pool) -> Result<(), SdkError> { @@ -124,10 +126,12 @@ impl CyclopsClient { json_request("DELETE", item_url, None), &[200, 202, 204, 404], ) - .await?; + .await + .map_err(|error| SdkError::deny_pool_access(&pool.metadata.namespace, error))?; Arc::clone(&self) .delete_namespace(pool.metadata.namespace.clone()) .await + .map_err(|error| SdkError::deny_pool_access(&pool.metadata.namespace, error)) } } diff --git a/libs/fleet/sdk/src/templates.rs b/libs/fleet/sdk/src/templates.rs index 5b0df5adfc..b4eac62c70 100644 --- a/libs/fleet/sdk/src/templates.rs +++ b/libs/fleet/sdk/src/templates.rs @@ -42,6 +42,7 @@ impl CyclopsClient { &[200, 201, 202], ) .await + .map_err(|error| SdkError::deny_pool_access(&template.metadata.namespace, error)) } pub async fn list_templates( @@ -105,6 +106,7 @@ impl CyclopsClient { &[200], ) .await + .map_err(|error| SdkError::deny_pool_access(&template.metadata.namespace, error)) } pub async fn delete_template(self: Arc, template: Template) -> Result<(), SdkError> { @@ -119,6 +121,7 @@ impl CyclopsClient { &[200, 202, 204, 404], ) .await + .map_err(|error| SdkError::deny_pool_access(&template.metadata.namespace, error)) } } diff --git a/libs/fleet/sdk/tests/pool_flow.rs b/libs/fleet/sdk/tests/pool_flow.rs index 6b3c214eef..0a9f131e26 100644 --- a/libs/fleet/sdk/tests/pool_flow.rs +++ b/libs/fleet/sdk/tests/pool_flow.rs @@ -538,6 +538,144 @@ async fn reconcile_returns_create_error_after_named_pool_is_forbidden() { assert_request(&requests[2], "POST", COLLECTION, Some(&json_bytes(&pool()))); } +#[tokio::test] +async fn create_pool_403_maps_to_pool_access_denied_with_guidance() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(409, b"already exists")), + Ok(response(403, b"k8s request is not allowed")), + ])); + let client = client(Arc::clone(&http)); + + let error = client + .create_pool(CreatePoolRequest { + namespace: NAMESPACE.into(), + spec: pool_spec(), + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + ref body, + } if operation == "create pool" + && namespace == NAMESPACE + && body == "k8s request is not allowed" + )); + let message = error.to_string(); + assert!(message.contains("globally unique")); + assert!(message.contains("try a new pool name")); + assert!(message.contains("https://discord.gg/mVnXXpdE85")); +} + +#[tokio::test] +async fn namespace_create_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(403, b"cannot create namespace")), + ])); + let client = client(Arc::clone(&http)); + + let error = client + .create_pool(CreatePoolRequest { + namespace: NAMESPACE.into(), + spec: pool_spec(), + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "create namespace" && namespace == NAMESPACE + )); +} + +#[tokio::test] +async fn reconcile_update_403_maps_to_pool_access_denied() { + let existing = pool(); + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(json_response(200, &existing)), + Ok(response(403, b"k8s request is not allowed")), + ])); + let client = client(Arc::clone(&http)); + + let error = client + .reconcile_pool(CreatePoolRequest { + namespace: NAMESPACE.into(), + spec: pool_spec(), + }) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "update pool" && namespace == NAMESPACE + )); +} + +#[tokio::test] +async fn delete_pool_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(403, b"k8s request is not allowed")), + ])); + let client = client(Arc::clone(&http)); + + let error = client.delete_pool(pool()).await.unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "delete pool" && namespace == NAMESPACE + )); +} + +#[tokio::test] +async fn delete_pool_namespace_cleanup_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(204, b"")), + Ok(response(403, b"cannot delete namespace")), + ])); + let client = client(Arc::clone(&http)); + + let error = client.delete_pool(pool()).await.unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "delete namespace" && namespace == NAMESPACE + )); + + let requests = resource_requests(&http).await; + assert_eq!(requests.len(), 2); + assert_request(&requests[0], "DELETE", ITEM, None); + assert_request(&requests[1], "DELETE", NAMESPACE_ITEM, None); +} + #[tokio::test] async fn reconcile_returns_unrelated_named_pool_read_error_without_creating() { let http = Arc::new(ScriptedHttpClient::new([ diff --git a/libs/fleet/sdk/tests/template_flow.rs b/libs/fleet/sdk/tests/template_flow.rs index 6d52161c5b..1d957898da 100644 --- a/libs/fleet/sdk/tests/template_flow.rs +++ b/libs/fleet/sdk/tests/template_flow.rs @@ -2,7 +2,7 @@ mod support; use cyclops_sdk::{ CreateTemplateRequest, CyclopsClient, CyclopsConfiguration, CyclopsCredentials, HttpHeader, - HttpResponse, ResourceMetadata, Template, + HttpResponse, ResourceMetadata, SdkError, Template, }; use std::sync::Arc; use support::ScriptedHttpClient; @@ -90,6 +90,81 @@ async fn reconcile_template_keeps_a_pull_secret_the_desired_spec_asks_for() { ); } +#[tokio::test] +async fn reconcile_template_update_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(json_response(200, &template(None))), + Ok(response(403, b"k8s request is not allowed")), + ])); + + let error = client(Arc::clone(&http)) + .reconcile_template(create_request(None)) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + ref body, + } if operation == "update template" + && namespace == NAMESPACE + && body == "k8s request is not allowed" + )); + let message = error.to_string(); + assert!(message.contains("globally unique")); + assert!(message.contains("https://discord.gg/mVnXXpdE85")); +} + +#[tokio::test] +async fn create_template_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(403, b"k8s request is not allowed")), + ])); + + let error = client(Arc::clone(&http)) + .create_template(create_request(None)) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "create template" && namespace == NAMESPACE + )); +} + +#[tokio::test] +async fn delete_template_403_maps_to_pool_access_denied() { + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(response(403, b"k8s request is not allowed")), + ])); + + let error = client(Arc::clone(&http)) + .delete_template(template(None)) + .await + .unwrap_err(); + + assert!(matches!( + error, + SdkError::PoolAccessDenied { + ref operation, + ref namespace, + status: 403, + .. + } if operation == "delete template" && namespace == NAMESPACE + )); +} + fn client(http: Arc) -> Arc { CyclopsClient::connect( CyclopsConfiguration { From a383ed7d7d3bd72036d500ca4f2b43d10f9781b1 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 11:12:09 -0700 Subject: [PATCH 059/117] ci(fleet): promote the cua-fleet 0.1.12 wheel set to PyPI (#3233) Repin the promotion workflow to the 0.1.12 wheels published to wheels.cua.ai from trycua/cloud tag cua-fleet-sdk/v0.1.12, which carry SdkError::PoolAccessDenied for 403s on pool-namespace writes (cloud PR #7013). Co-authored-by: Claude Fable 5 --- .github/workflows/cd-py-fleet.yml | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cd-py-fleet.yml b/.github/workflows/cd-py-fleet.yml index 93911b0e76..be555db7b1 100644 --- a/.github/workflows/cd-py-fleet.yml +++ b/.github/workflows/cd-py-fleet.yml @@ -9,7 +9,7 @@ on: version: description: "Canonical cua-fleet version to publish" required: true - default: "0.1.11" + default: "0.1.12" workflow_call: inputs: version: @@ -49,8 +49,8 @@ jobs: exit 1 fi - if [[ "$VERSION" != "0.1.11" ]]; then - echo "::error::This workflow promotes the hash-pinned cua-fleet 0.1.11 wheel set, not $VERSION." + if [[ "$VERSION" != "0.1.12" ]]; then + echo "::error::This workflow promotes the hash-pinned cua-fleet 0.1.12 wheel set, not $VERSION." exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -65,32 +65,32 @@ jobs: include: - platform: linux-x86_64 runner: ubuntu-22.04 - wheel: cua_fleet-0.1.11-py3-none-manylinux_2_34_x86_64.whl - sha256: 0539a40aac915368362dd2f3e90d4b6d233e7bece77d13a5733d8cecb7298731 + wheel: cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl + sha256: d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a native_library: libcyclops_sdk.so audit: auditwheel - platform: linux-aarch64 runner: ubuntu-24.04-arm - wheel: cua_fleet-0.1.11-py3-none-manylinux_2_34_aarch64.whl - sha256: c76052d9cb1710936a59709fd5a2894fe9fb43fe89e2a0313ad0b88faf4c2b1e + wheel: cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl + sha256: 0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973 native_library: libcyclops_sdk.so audit: auditwheel - platform: macos-x86_64 runner: macos-15-intel - wheel: cua_fleet-0.1.11-py3-none-macosx_10_12_x86_64.whl - sha256: 5884a26388b44e9cf59314d20b9aae34f278c48b108d927ebaf802b8d5b7d7f6 + wheel: cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl + sha256: d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75 native_library: libcyclops_sdk.dylib audit: delocate - platform: macos-arm64 runner: macos-14 - wheel: cua_fleet-0.1.11-py3-none-macosx_11_0_arm64.whl - sha256: 1f6d1532f76166fe30001c68765c4f3fcb94e9b713751f7a009d3780f523aa9c + wheel: cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl + sha256: 82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2 native_library: libcyclops_sdk.dylib audit: delocate - platform: windows-x86_64 runner: windows-latest - wheel: cua_fleet-0.1.11-py3-none-win_amd64.whl - sha256: da8e1c40abcd3fee5cdab48801d4bd43a277ecddb7afebcba3d9885d9ec5d431 + wheel: cua_fleet-0.1.12-py3-none-win_amd64.whl + sha256: c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75 native_library: cyclops_sdk.dll audit: none steps: @@ -246,11 +246,11 @@ jobs: version, dist_directory = sys.argv[1:] expected = { - f"cua_fleet-{version}-py3-none-manylinux_2_34_x86_64.whl": "0539a40aac915368362dd2f3e90d4b6d233e7bece77d13a5733d8cecb7298731", - f"cua_fleet-{version}-py3-none-manylinux_2_34_aarch64.whl": "c76052d9cb1710936a59709fd5a2894fe9fb43fe89e2a0313ad0b88faf4c2b1e", - f"cua_fleet-{version}-py3-none-macosx_10_12_x86_64.whl": "5884a26388b44e9cf59314d20b9aae34f278c48b108d927ebaf802b8d5b7d7f6", - f"cua_fleet-{version}-py3-none-macosx_11_0_arm64.whl": "1f6d1532f76166fe30001c68765c4f3fcb94e9b713751f7a009d3780f523aa9c", - f"cua_fleet-{version}-py3-none-win_amd64.whl": "da8e1c40abcd3fee5cdab48801d4bd43a277ecddb7afebcba3d9885d9ec5d431", + f"cua_fleet-{version}-py3-none-manylinux_2_34_x86_64.whl": "d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a", + f"cua_fleet-{version}-py3-none-manylinux_2_34_aarch64.whl": "0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973", + f"cua_fleet-{version}-py3-none-macosx_10_12_x86_64.whl": "d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75", + f"cua_fleet-{version}-py3-none-macosx_11_0_arm64.whl": "82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2", + f"cua_fleet-{version}-py3-none-win_amd64.whl": "c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75", } wheels = {wheel.name: wheel for wheel in Path(dist_directory).glob("*.whl")} assert set(wheels) == set(expected), (set(wheels), set(expected)) From 9b625d7dceb9c09a2e91aa6dde873d0e0aa0cd4a Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 11:12:46 -0700 Subject: [PATCH 060/117] test(cua-sandbox): exercise persistent pre-provisioned pools in periodic live E2E (#3218) * test(cua-sandbox): exercise persistent pre-provisioned pools in periodic live E2E The periodic live Fleet E2E only covered Sandbox.ephemeral(), which creates and destroys a pool every run. Add a second suite that claims from persistent pools that survive across runs, covering the pre-provisioned consumer path: - new tests/live/test_fleet_pool_persistent.py with two modes: a warm pool (replicas=1, claims bind to already-running sandboxes with a conditional bind SLA) and a scale-to-zero pool expressed via WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1) since Pool.apply rejects replicas below one - each run observes the pool (pool_pre_existed, replica counts), reconciles the pinned config idempotently with Pool.apply, claims through Sandbox.ephemeral(pool=..., name=...) with the claim name fixed to the namespace for self-healing reuse, and verifies a claim-only release: the reconciled inventory must retain exactly the named pool and template with zero claims - the workflow prepare matrix becomes lane x suite; pushes keep running only the ephemeral suite while schedule crosses both lanes with both suites and dispatch gains a suite input; concurrency, artifact names, controlled failure diagnostics, and Alertmanager labels carry the suite - offline unit tests cover the new runner and namespace helpers; the workflow contract test and the superpowers spec/plan docs are updated together Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RZC4ofqRTQ52tH9TTRQwKc * test(scripts): align cua-fleet release wiring test with the 0.1.11 promotion The cd-py-fleet.yml workflow was moved to the hash-pinned cua-fleet 0.1.11 wheel set in #3217, but the wiring regression test still pinned the 0.1.8 wheels. CI: Test Scripts only runs on pull requests touching .github/scripts/**, so the drift stayed latent until this branch ran the suite. Pin the test to the reviewed 0.1.11 wheel names and digests from the workflow on main. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RZC4ofqRTQ52tH9TTRQwKc --------- Co-authored-by: Claude Fable 5 --- .../tests/test_periodic_cua_sandbox_live.py | 129 +++++-- .../ci-public-containerdisk-refs.yml | 1 + .../workflows/periodic-cua-sandbox-live.yml | 51 ++- ...026-08-09-periodic-cua-sandbox-live-e2e.md | 33 ++ ...09-periodic-cua-sandbox-live-e2e-design.md | 115 ++++-- .../tests/live/fleet_e2e_support.py | 22 +- .../tests/live/test_fleet_pool_persistent.py | 284 ++++++++++++++ .../tests/test_live_fleet_e2e_support.py | 35 ++ .../tests/test_live_fleet_pool_support.py | 348 ++++++++++++++++++ 9 files changed, 946 insertions(+), 72 deletions(-) create mode 100644 libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py create mode 100644 libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py diff --git a/.github/scripts/tests/test_periodic_cua_sandbox_live.py b/.github/scripts/tests/test_periodic_cua_sandbox_live.py index 277b01c703..316ab91512 100644 --- a/.github/scripts/tests/test_periodic_cua_sandbox_live.py +++ b/.github/scripts/tests/test_periodic_cua_sandbox_live.py @@ -15,6 +15,7 @@ WORKFLOW = REPO_ROOT / ".github/workflows/periodic-cua-sandbox-live.yml" SANDBOX_ROOT = REPO_ROOT / "libs/python/cua-sandbox" LIVE_TEST = SANDBOX_ROOT / "tests/live/test_fleet_ephemeral.py" +POOL_LIVE_TEST = SANDBOX_ROOT / "tests/live/test_fleet_pool_persistent.py" class TestPeriodicCuaSandboxLive(unittest.TestCase): @@ -29,13 +30,16 @@ def steps_by_name(job: dict[str, object]) -> dict[str, dict[str, object]]: step["name"]: step for step in job["steps"] if isinstance(step, dict) and "name" in step } - def run_prepare_matrix(self, event_name: str, requested_lane: str) -> dict[str, object]: + def run_prepare_matrix( + self, event_name: str, requested_lane: str, requested_suite: str = "" + ) -> dict[str, object]: prepare_script = self.workflow()["jobs"]["prepare"]["steps"][0]["run"] with tempfile.TemporaryDirectory() as temporary_directory: github_output = Path(temporary_directory) / "github-output" environment = os.environ | { "EVENT_NAME": event_name, "REQUESTED_LANE": requested_lane, + "REQUESTED_SUITE": requested_suite, "GITHUB_OUTPUT": str(github_output), } subprocess.run( @@ -69,14 +73,17 @@ def test_trigger_and_lane_structure(self) -> None: inputs["lane"]["options"], ["both", "main-source", "published-package"], ) + self.assertEqual(inputs["suite"]["options"], ["both", "ephemeral", "pool"]) + self.assertEqual(inputs["suite"]["default"], "both") self.assertEqual(inputs["force_failure"]["type"], "boolean") prepare = workflow["jobs"]["prepare"] self.assertEqual(prepare["outputs"]["matrix"], "${{ steps.matrix.outputs.matrix }}") prepare_script = prepare["steps"][0]["run"] self.assertIn('[[ "$EVENT_NAME" == "push" ]]', prepare_script) - self.assertIn('"lane":"main-source"', prepare_script) - self.assertIn('"lane":"published-package"', prepare_script) + self.assertIn('{"lane":"main-source","suite":"ephemeral"}', prepare_script) + self.assertIn('lanes=("main-source" "published-package")', prepare_script) + self.assertIn('suites=("ephemeral" "pool")', prepare_script) def test_jobs_only_run_in_the_upstream_repository(self) -> None: workflow = self.workflow() @@ -87,37 +94,34 @@ def test_jobs_only_run_in_the_upstream_repository(self) -> None: "github.repository == 'trycua/cua'", ) - def test_prepare_matrix_selects_lanes_for_each_trigger(self) -> None: + def test_prepare_matrix_selects_lanes_and_suites_for_each_trigger(self) -> None: + main_ephemeral = {"lane": "main-source", "suite": "ephemeral"} + main_pool = {"lane": "main-source", "suite": "pool"} + published_ephemeral = {"lane": "published-package", "suite": "ephemeral"} + published_pool = {"lane": "published-package", "suite": "pool"} + all_lanes_and_suites = { + "include": [main_ephemeral, main_pool, published_ephemeral, published_pool] + } expected_matrices = { - ("push", ""): {"include": [{"lane": "main-source"}]}, - ("push", "both"): {"include": [{"lane": "main-source"}]}, - ("push", "published-package"): {"include": [{"lane": "main-source"}]}, - ("schedule", ""): { - "include": [ - {"lane": "main-source"}, - {"lane": "published-package"}, - ] - }, - ("schedule", "main-source"): { - "include": [ - {"lane": "main-source"}, - {"lane": "published-package"}, - ] - }, - ("workflow_dispatch", "both"): { - "include": [ - {"lane": "main-source"}, - {"lane": "published-package"}, - ] - }, - ("workflow_dispatch", "main-source"): {"include": [{"lane": "main-source"}]}, - ("workflow_dispatch", "published-package"): { - "include": [{"lane": "published-package"}] + ("push", "", ""): {"include": [main_ephemeral]}, + ("push", "both", "both"): {"include": [main_ephemeral]}, + ("push", "published-package", "pool"): {"include": [main_ephemeral]}, + ("schedule", "", ""): all_lanes_and_suites, + ("schedule", "main-source", "pool"): all_lanes_and_suites, + ("workflow_dispatch", "both", "both"): all_lanes_and_suites, + ("workflow_dispatch", "main-source", ""): {"include": [main_ephemeral, main_pool]}, + ("workflow_dispatch", "main-source", "both"): {"include": [main_ephemeral, main_pool]}, + ("workflow_dispatch", "main-source", "pool"): {"include": [main_pool]}, + ("workflow_dispatch", "both", "pool"): {"include": [main_pool, published_pool]}, + ("workflow_dispatch", "published-package", "ephemeral"): { + "include": [published_ephemeral] }, } for inputs, expected_matrix in expected_matrices.items(): - with self.subTest(event_name=inputs[0], requested_lane=inputs[1]): + with self.subTest( + event_name=inputs[0], requested_lane=inputs[1], requested_suite=inputs[2] + ): self.assertEqual(self.run_prepare_matrix(*inputs), expected_matrix) def test_docs_describe_the_remediated_workflow(self) -> None: @@ -139,6 +143,14 @@ def test_docs_describe_the_remediated_workflow(self) -> None: "persistent reconciled resources", "claim-only cleanup", "cua-live-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", + "periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}-${{ matrix.suite }}", + "cua-live-pool-warm-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", + "cua-live-pool-cold-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", + "Run live Fleet pool smoke", + "test_fleet_pool_persistent.py", + "WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1)", + "pool_pre_existed", + "claim-only release", ) stale_contract = ( "Concurrency is scoped\nper lane", @@ -171,7 +183,7 @@ def test_live_job_security_and_execution_structure(self) -> None: ) self.assertEqual( live["concurrency"]["group"], - "periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}", + "periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}-${{ matrix.suite }}", ) self.assertEqual( live["concurrency"]["cancel-in-progress"], @@ -194,15 +206,30 @@ def test_live_job_security_and_execution_structure(self) -> None: { "Check Fleet OAuth credentials": expected_oauth_env, "Run live Fleet smoke": expected_oauth_env, + "Run live Fleet pool smoke": expected_oauth_env, }, ) self.assertEqual(live["env"]["CUA_LIVE_E2E_EVENT"], "${{ github.event_name }}") + self.assertEqual(live["env"]["CUA_LIVE_E2E_SUITE"], "${{ matrix.suite }}") self.assertEqual( live["env"]["CUA_LIVE_E2E_NAMESPACE"], "cua-live-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", ) - self.assertNotIn("github.run_id", live["env"]["CUA_LIVE_E2E_NAMESPACE"]) - self.assertNotIn("github.run_attempt", live["env"]["CUA_LIVE_E2E_NAMESPACE"]) + self.assertEqual( + live["env"]["CUA_LIVE_E2E_POOL_WARM_NAMESPACE"], + "cua-live-pool-warm-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", + ) + self.assertEqual( + live["env"]["CUA_LIVE_E2E_POOL_COLD_NAMESPACE"], + "cua-live-pool-cold-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}", + ) + for namespace_env in ( + "CUA_LIVE_E2E_NAMESPACE", + "CUA_LIVE_E2E_POOL_WARM_NAMESPACE", + "CUA_LIVE_E2E_POOL_COLD_NAMESPACE", + ): + self.assertNotIn("github.run_id", live["env"][namespace_env]) + self.assertNotIn("github.run_attempt", live["env"][namespace_env]) step_names = [step["name"] for step in live["steps"]] self.assertLess( @@ -246,10 +273,19 @@ def test_live_job_security_and_execution_structure(self) -> None: self.assertIn("tests/live", isolated_suite) live_step = steps["Run live Fleet smoke"] self.assertEqual(live_step["env"], expected_oauth_env) + self.assertIn("matrix.suite == 'ephemeral'", live_step["if"]) live_run = live_step["run"] self.assertIn("$CUA_LIVE_E2E_TEST_ROOT/tests/live/test_fleet_ephemeral.py", live_run) self.assertNotIn("libs/python/cua-sandbox/tests/live", live_run) self.assertIn('PYTHONPATH="$CUA_LIVE_E2E_TEST_ROOT', live_run) + pool_step = steps["Run live Fleet pool smoke"] + self.assertEqual(pool_step["env"], expected_oauth_env) + self.assertIn("matrix.suite == 'pool'", pool_step["if"]) + self.assertIn("force_failure", pool_step["if"]) + pool_run = pool_step["run"] + self.assertIn("$CUA_LIVE_E2E_TEST_ROOT/tests/live/test_fleet_pool_persistent.py", pool_run) + self.assertNotIn("libs/python/cua-sandbox/tests/live", pool_run) + self.assertIn('PYTHONPATH="$CUA_LIVE_E2E_TEST_ROOT', pool_run) def test_failure_diagnostics_alerting_and_pinned_actions(self) -> None: workflow = self.workflow() @@ -265,6 +301,11 @@ def test_failure_diagnostics_alerting_and_pinned_actions(self) -> None: ) self.assertEqual(steps["Upload failure diagnostics"]["if"], "failure()") self.assertEqual(steps["Upload failure diagnostics"]["with"]["retention-days"], "7") + self.assertEqual( + steps["Upload failure diagnostics"]["with"]["name"], + "cua-sandbox-live-${{ matrix.lane }}-${{ matrix.suite }}" + "-${{ github.run_id }}-${{ github.run_attempt }}", + ) controlled_summary = steps["Write controlled failure diagnostics"] self.assertEqual( @@ -274,6 +315,7 @@ def test_failure_diagnostics_alerting_and_pinned_actions(self) -> None: self.assertIn("summary.json", controlled_summary["run"]) self.assertIn("ControlledFailure", controlled_summary["run"]) self.assertIn('os.environ["CUA_LIVE_E2E_SOURCE_SHA"]', controlled_summary["run"]) + self.assertIn('os.environ["CUA_LIVE_E2E_SUITE"]', controlled_summary["run"]) self.assertNotIn('os.environ.get("GITHUB_SHA")', controlled_summary["run"]) self.assertEqual( steps["Controlled alert test failure"]["if"], @@ -289,6 +331,7 @@ def test_failure_diagnostics_alerting_and_pinned_actions(self) -> None: self.assertIn("https://am.cua.ai/api/v2/alerts", alert["run"]) self.assertIn("PeriodicCuaSandboxLiveE2EFailed", alert["run"]) self.assertIn('"lane": "${{ matrix.lane }}"', alert["run"]) + self.assertIn('"suite": "${{ matrix.suite }}"', alert["run"]) self.assertIn("${{ steps.versions.outputs.sandbox }}", alert["run"]) self.assertIn("${{ steps.source_sha.outputs.source_sha }}", alert["run"]) self.assertNotIn('"source_sha": "${{ github.sha }}"', alert["run"]) @@ -310,6 +353,28 @@ def test_cleanup_is_claim_only_and_inventory_is_diagnostic(self) -> None: self.assertIn("module_origins", live_test) self.assertIn("cua_sandbox", live_test) + def test_pool_suite_is_claim_only_and_pool_is_persistent(self) -> None: + workflow = WORKFLOW.read_text() + pool_test = POOL_LIVE_TEST.read_text() + + self.assertIn("Run live Fleet pool smoke", workflow) + self.assertIn("Sandbox.ephemeral", pool_test) + self.assertIn("Pool.apply", pool_test) + self.assertIn( + "WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1)", + pool_test, + ) + self.assertIn("wait_claims_absent", pool_test) + self.assertIn("claim_leak", pool_test) + self.assertIn("persistent_resources", pool_test) + self.assertIn("unexpected_inventory", pool_test) + self.assertIn("module_origins", pool_test) + self.assertIn("pool_pre_existed", pool_test) + self.assertNotIn("pool.delete(", pool_test) + self.assertNotIn("delete_pool", pool_test) + self.assertNotIn("delete_namespace", pool_test) + self.assertNotIn("keep_pool", pool_test) + def test_isolated_suite_cannot_import_checkout_cua_sandbox(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: temporary_root = Path(temporary_directory) diff --git a/.github/workflows/ci-public-containerdisk-refs.yml b/.github/workflows/ci-public-containerdisk-refs.yml index 5a7bd2c201..c8cd4e88b5 100644 --- a/.github/workflows/ci-public-containerdisk-refs.yml +++ b/.github/workflows/ci-public-containerdisk-refs.yml @@ -10,6 +10,7 @@ on: - "infra/fleets-wif-smoke/**" - "libs/fleet/backend/auth/**" - "libs/python/cua-sandbox/tests/live/test_fleet_ephemeral.py" + - "libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py" - "tests/test_public_containerdisk_refs.py" permissions: diff --git a/.github/workflows/periodic-cua-sandbox-live.yml b/.github/workflows/periodic-cua-sandbox-live.yml index 66cb68f9cd..e23ea05839 100644 --- a/.github/workflows/periodic-cua-sandbox-live.yml +++ b/.github/workflows/periodic-cua-sandbox-live.yml @@ -18,6 +18,12 @@ on: default: both type: choice options: [both, main-source, published-package] + suite: + description: "Suite to run" + required: true + default: both + type: choice + options: [both, ephemeral, pool] force_failure: description: "Fail after setup to certify alerting" required: true @@ -39,14 +45,27 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} REQUESTED_LANE: ${{ inputs.lane }} + REQUESTED_SUITE: ${{ inputs.suite }} run: | set -euo pipefail if [[ "$EVENT_NAME" == "push" ]]; then - matrix='{"include":[{"lane":"main-source"}]}' - elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$REQUESTED_LANE" != "both" ]]; then - matrix="{\"include\":[{\"lane\":\"$REQUESTED_LANE\"}]}" + matrix='{"include":[{"lane":"main-source","suite":"ephemeral"}]}' else - matrix='{"include":[{"lane":"main-source"},{"lane":"published-package"}]}' + lanes=("main-source" "published-package") + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$REQUESTED_LANE" != "both" ]]; then + lanes=("$REQUESTED_LANE") + fi + suites=("ephemeral" "pool") + if [[ "$EVENT_NAME" == "workflow_dispatch" && "${REQUESTED_SUITE:-both}" != "both" ]]; then + suites=("$REQUESTED_SUITE") + fi + include="" + for lane in "${lanes[@]}"; do + for suite in "${suites[@]}"; do + include+="{\"lane\":\"$lane\",\"suite\":\"$suite\"}," + done + done + matrix="{\"include\":[${include%,}]}" fi echo "matrix=$matrix" >> "$GITHUB_OUTPUT" @@ -59,13 +78,16 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} concurrency: - group: periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }} + group: periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}-${{ matrix.suite }} cancel-in-progress: ${{ github.event_name == 'schedule' }} env: CUA_FLEET_BASE_URL: https://run.cua.ai CUA_LIVE_E2E_LANE: ${{ matrix.lane }} + CUA_LIVE_E2E_SUITE: ${{ matrix.suite }} CUA_LIVE_E2E_EVENT: ${{ github.event_name }} CUA_LIVE_E2E_NAMESPACE: cua-live-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }} + CUA_LIVE_E2E_POOL_WARM_NAMESPACE: cua-live-pool-warm-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }} + CUA_LIVE_E2E_POOL_COLD_NAMESPACE: cua-live-pool-cold-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }} CUA_LIVE_E2E_ARTIFACT_DIR: /tmp/cua-live-e2e CUA_TELEMETRY_ENABLED: "false" steps: @@ -154,6 +176,7 @@ jobs: json.dumps( { "lane": os.environ["CUA_LIVE_E2E_LANE"], + "suite": os.environ["CUA_LIVE_E2E_SUITE"], "namespace": os.environ["CUA_LIVE_E2E_NAMESPACE"], "source_sha": os.environ["CUA_LIVE_E2E_SOURCE_SHA"], "error": {"type": "ControlledFailure"}, @@ -170,7 +193,7 @@ jobs: run: exit 1 - name: Run live Fleet smoke - if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.force_failure) }} + if: ${{ matrix.suite == 'ephemeral' && !(github.event_name == 'workflow_dispatch' && inputs.force_failure) }} env: CUA_CLIENT_ID: ${{ secrets.CUA_CLIENT_ID }} CUA_CLIENT_SECRET: ${{ secrets.CUA_CLIENT_SECRET }} @@ -178,11 +201,20 @@ jobs: PYTHONPATH="$CUA_LIVE_E2E_TEST_ROOT" python -m pytest -q -s \ "$CUA_LIVE_E2E_TEST_ROOT/tests/live/test_fleet_ephemeral.py" + - name: Run live Fleet pool smoke + if: ${{ matrix.suite == 'pool' && !(github.event_name == 'workflow_dispatch' && inputs.force_failure) }} + env: + CUA_CLIENT_ID: ${{ secrets.CUA_CLIENT_ID }} + CUA_CLIENT_SECRET: ${{ secrets.CUA_CLIENT_SECRET }} + run: | + PYTHONPATH="$CUA_LIVE_E2E_TEST_ROOT" python -m pytest -q -s \ + "$CUA_LIVE_E2E_TEST_ROOT/tests/live/test_fleet_pool_persistent.py" + - name: Upload failure diagnostics if: failure() uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4 with: - name: cua-sandbox-live-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }} + name: cua-sandbox-live-${{ matrix.lane }}-${{ matrix.suite }}-${{ github.run_id }}-${{ github.run_attempt }} path: /tmp/cua-live-e2e if-no-files-found: warn retention-days: 7 @@ -201,10 +233,11 @@ jobs: "severity": "critical", "service": "cua-sandbox", "job": "periodic-cua-sandbox-live", - "lane": "${{ matrix.lane }}" + "lane": "${{ matrix.lane }}", + "suite": "${{ matrix.suite }}" }, "annotations": { - "summary": "Cua Sandbox live Fleet E2E failed (${{ matrix.lane }})", + "summary": "Cua Sandbox live Fleet E2E failed (${{ matrix.lane }}/${{ matrix.suite }})", "description": "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", "source_sha": "${{ steps.source_sha.outputs.source_sha }}", "package_version": "${{ steps.versions.outputs.sandbox }}", diff --git a/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md b/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md index 2aeb54fb3d..84d286adda 100644 --- a/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md +++ b/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md @@ -906,3 +906,36 @@ The workflow uses step-scoped OAuth credentials only for credential preflight an live pytest step. After checkout, `git rev-parse HEAD` is exported as `CUA_LIVE_E2E_SOURCE_SHA` and is the source SHA recorded by live, controlled-failure, and Alertmanager evidence. + +## Persistent Pool Suite + +The workflow later gained a second suite that claims from persistent, +pre-provisioned Fleet pools instead of creating and destroying a pool per run. +The prepare job emits a lane-and-suite matrix: pushes stay on the `ephemeral` +suite only, while scheduled runs cross both lanes with both suites and manual +dispatch selects lane and suite combinations. The concurrency group is +`periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}-${{ matrix.suite }}`, +so a newer schedule cancels only an older scheduled run of the same lane and +suite. + +The suite's step, `Run live Fleet pool smoke`, executes +`tests/live/test_fleet_pool_persistent.py` from the same isolated copied suite +with step-scoped OAuth credentials. Two pool namespaces per lane and event +class are set by the workflow: + +- `cua-live-pool-warm-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}` +- `cua-live-pool-cold-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}` + +The warm pool keeps `replicas=1` so claims bind to pre-provisioned capacity; +the cold pool expresses scale-to-zero with +`WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1)` +because pool reconciliation rejects `replicas` below one. Each run records +`pool_pre_existed` and replica counts from `Pool.get`, reconciles the pinned +configuration idempotently with `Pool.apply`, claims through +`Sandbox.ephemeral(pool=..., name=...)` with the claim name fixed to the +namespace, and exits with a claim-only release. After release the monitor +polls until claims are absent and requires the reconciled inventory to contain +exactly the named pool and template with zero claims; the pool and template +deliberately persist between runs. The warm mode asserts a claim-acquisition +bound only when the pool pre-existed with a ready replica. Failure artifacts +and Alertmanager labels carry the suite alongside the lane. diff --git a/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md b/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md index 755d8eca63..35d6240620 100644 --- a/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md +++ b/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md @@ -18,6 +18,8 @@ testing the current Fleet-backed public SDK contract. - Exercise Fleet-backed `Sandbox.ephemeral()` against live production infrastructure every 15 minutes. +- Exercise claiming from persistent, pre-provisioned Fleet pools — both a warm + pool and a scale-to-zero pool — on the same cadence. - Test both the current repository `main` source and the latest published `cua-sandbox` package. - Run the source lane immediately after relevant changes merge to `main`. @@ -49,31 +51,34 @@ testing the current Fleet-backed public SDK contract. `libs/python/cua-fleet/**`, `.github/workflows/periodic-cua-sandbox-live.yml`, and `.github/scripts/tests/test_periodic_cua_sandbox_live.py`. -3. `workflow_dispatch` accepts `both`, `main-source`, or `published-package`, - plus manual-only `force_failure`. +3. `workflow_dispatch` accepts a lane (`both`, `main-source`, or + `published-package`), a suite (`both`, `ephemeral`, or `pool`), plus + manual-only `force_failure`. Both jobs are guarded with `if: github.repository == 'trycua/cua'`. A fork that syncs `main` or enables the schedule therefore never runs the live smoke, never fails the credential preflight, and never posts a fork-originated `PeriodicCuaSandboxLiveE2EFailed` alert to the public Alertmanager endpoint. -The preparation script emits a JSON matrix: every `push` selects only -`main-source`; every `schedule` selects both lanes; manual runs select their -requested lane or both. The workflow contract executes this extracted shell -script in `bash` with a temporary `GITHUB_OUTPUT` and parses the emitted JSON, -so a push-to-both mutation or ignored manual selection fails CI. +The preparation script emits a JSON lane-and-suite matrix: every `push` +selects only `main-source` with the `ephemeral` suite; every `schedule` +selects both lanes crossed with both suites; manual runs select their +requested lane and suite combinations. The workflow contract executes this +extracted shell script in `bash` with a temporary `GITHUB_OUTPUT` and parses +the emitted JSON, so a push-to-both mutation or ignored manual selection fails +CI. -The two lanes use `fail-fast: false` and this concurrency contract: +The matrix jobs use `fail-fast: false` and this concurrency contract: ```yaml concurrency: - group: periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }} + group: periodic-cua-sandbox-live-${{ github.event_name }}-${{ matrix.lane }}-${{ matrix.suite }} cancel-in-progress: ${{ github.event_name == 'schedule' }} ``` -The event-and-lane grouping means a new schedule can cancel only an older -schedule for the same lane. Push and manual runs use distinct groups and are -allowed to finish. +The event-lane-and-suite grouping means a new schedule can cancel only an +older schedule for the same lane and suite. Push and manual runs use distinct +groups and are allowed to finish. ## Installation Isolation @@ -173,6 +178,47 @@ and workflow never explicitly delete a namespace, pool, or template: name-only deletes can race with reconciliation. Cleanup errors are reported alongside the primary exception. +## Persistent Pool Suite + +The `pool` suite exercises the pre-provisioned pool consumer path that the +`ephemeral` suite cannot: pools that survive across runs and hand out claims. +`Run live Fleet pool smoke` executes +`libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py` from the +same isolated copied suite. The suite runs on `schedule` and +`workflow_dispatch` only; pushes keep running only the `ephemeral` suite. + +Each lane and event class owns two persistent pool namespaces, set by the +workflow as: + +- `cua-live-pool-warm-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}` +- `cua-live-pool-cold-${{ matrix.lane }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || github.event_name }}` + +The warm mode keeps `replicas=1`, so a scheduled claim binds to an +already-running sandbox and releasing the claim recycles that sandbox back +into the pool. The cold mode expresses scale-to-zero with +`WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1)` +because pool reconciliation rejects `replicas` below one; a claim then +cold-starts capacity through autoscaler demand. + +Each run observes the pool first with `Pool.get`, recording +`pool_pre_existed` and the replica counts, then reconciles the pinned +configuration with `Pool.apply` using the same certified image digest, +`cpu=4`, and `memory_mb=4096`. Reconciliation is idempotent: it bootstraps a +missing pool, heals drift, and never deletes. The claim uses +`Sandbox.ephemeral(pool=..., name=...)` with the claim name fixed to the +namespace, so an interrupted run's claim is adopted and released by the next +run. Exiting the context performs a claim-only release: the pool and template +must persist. + +After release the monitor polls until claims are absent and requires the +reconciled inventory to contain exactly the named pool and template with zero +claims — the persistence mirror of the ephemeral suite's empty-inventory +invariant. Replica counts after release are recorded as telemetry only, +because warm-pool recycling and autoscaler decay are server-controlled. The +warm mode additionally asserts a claim-acquisition bound only when the pool +pre-existed with at least one ready replica; bootstrap runs record timing +without enforcing it. + ## Diagnostics And Artifacts The live test writes a sanitized JSON summary containing lane, source SHA, @@ -199,21 +245,25 @@ Each lane has an `if: failure()` notification step posting to - `service=cua-sandbox` - `job=periodic-cua-sandbox-live` - `lane=main-source|published-package` +- `suite=ephemeral|pool` Annotations include the failed GitHub Actions run, the source SHA or installed package version, the pinned image digest, and a link to the workflow dashboard. Do not include credentials or raw authorization failures that may contain headers. -The lane label lets Alertmanager group repeated failures without combining a -source regression with a published-package or shared-infrastructure failure. +The lane and suite labels let Alertmanager group repeated failures without +combining a source regression with a published-package failure, or an +ephemeral provisioning failure with a persistent pool claim failure. Failure +artifacts are named per lane and suite so concurrent matrix jobs never collide +on upload. ## Workflow Contract Coverage The repository-side contract parses the workflow with `yaml.BaseLoader` and asserts triggers, path filters, the upstream-repository fork guard on both jobs, the executed preparation matrix, checkout ref, -event-and-lane concurrency, credential preflight, copied-suite isolation, +event-lane-and-suite concurrency, credential preflight, copied-suite isolation, version output handling, controlled-failure diagnostics, failure-only artifacts, Alertmanager labels, full-SHA action pins, and absence of explicit deletion. Scripts CI installs `pyyaml` and runs this contract when the workflow changes. @@ -226,10 +276,14 @@ Scripts CI installs `pyyaml` and runs this contract when the workflow changes. complete claim-only cleanup. 3. Manually dispatch `published-package` and verify the installed release plus cleanup behavior. -4. Exercise the Alertmanager payload without exposing secrets, then resolve the +4. Manually dispatch the `pool` suite twice per lane: the first run + bootstraps both persistent pools (`pool_pre_existed` false), the second + proves a warm claim against pre-provisioned capacity and records cold + scale-to-zero telemetry. +5. Exercise the Alertmanager payload without exposing secrets, then resolve the test alert. -5. Enable the `7/15 * * * *` schedule. -6. Observe at least two consecutive scheduled runs for both lanes before +6. Enable the `7/15 * * * *` schedule. +7. Observe at least two consecutive scheduled runs for both lanes before considering rollout complete. ## Success Criteria @@ -240,8 +294,10 @@ Scripts CI installs `pyyaml` and runs this contract when the workflow changes. an older scheduled run of the same lane, while push and manual runs finish. - Port, screen, screenshot, and shell assertions all exercise the public SDK. - Normal runs retain only the named persistent pool/template with no claims; failed provisioning records read-only claim and inventory diagnostics without deleting resources. -- A forced failure produces one actionable, lane-specific Alertmanager alert - and sanitized failure artifacts. +- Pool-suite runs claim from and release back to persistent pools that survive + the run, with the warm pool binding against pre-provisioned capacity. +- A forced failure produces one actionable, lane- and suite-specific + Alertmanager alert and sanitized failure artifacts. ## Live Evidence Remediation @@ -252,10 +308,15 @@ Each lane has one DNS-safe namespace for each event class: - `cua-live--push` for pushes - `cua-live--manual` for `workflow_dispatch` -The event-and-lane concurrency group serializes use of each deterministic claim; -only scheduled runs cancel an older scheduled run in the same lane. Fleet -reconciliation preserves the namespace, pool, and template, all named after the -namespace. `Sandbox.ephemeral()` is verified with claim-only cleanup: after -exit the monitor polls until claims are absent, records persistent reconciled -resources, and requires exactly the named pool/template with zero claims. It -never explicitly deletes a namespace, pool, or template. +The persistent pool suite adds `cua-live-pool-warm--` and +`cua-live-pool-cold--` namespaces whose pools and templates +deliberately outlive every run. + +The event-lane-and-suite concurrency group serializes use of each +deterministic claim; only scheduled runs cancel an older scheduled run in the +same lane and suite. Fleet reconciliation preserves the namespace, pool, and +template, all named after the namespace. `Sandbox.ephemeral()` is verified +with claim-only cleanup: after exit the monitor polls until claims are absent, +records persistent reconciled resources, and requires exactly the named +pool/template with zero claims. It never explicitly deletes a namespace, pool, +or template. diff --git a/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py b/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py index d756bac37e..b60320fa91 100644 --- a/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py +++ b/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py @@ -53,17 +53,31 @@ async def aclose(self) -> None: await self._client.aclose() -def build_namespace_name(lane: str, event_name: str) -> str: - event_class = { +def has_oauth_credentials() -> bool: + return bool(os.environ.get("CUA_CLIENT_ID") and os.environ.get("CUA_CLIENT_SECRET")) + + +def _event_class(event_name: str) -> str: + return { "schedule": "schedule", "push": "push", "workflow_dispatch": "manual", }.get(event_name, "manual") - raw = f"cua-live-{lane}-{event_class}".lower() - normalized = re.sub(r"[^a-z0-9-]+", "-", raw).strip("-") + + +def _normalize_namespace_name(raw: str) -> str: + normalized = re.sub(r"[^a-z0-9-]+", "-", raw.lower()).strip("-") return normalized[:63].rstrip("-") +def build_namespace_name(lane: str, event_name: str) -> str: + return _normalize_namespace_name(f"cua-live-{lane}-{_event_class(event_name)}") + + +def build_pool_namespace_name(mode: str, lane: str, event_name: str) -> str: + return _normalize_namespace_name(f"cua-live-pool-{mode}-{lane}-{_event_class(event_name)}") + + def build_fleet_client() -> tuple[CyclopsClient, HttpxFleetClient]: client_id = os.environ["CUA_CLIENT_ID"] client_secret = os.environ["CUA_CLIENT_SECRET"] diff --git a/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py b/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py new file mode 100644 index 0000000000..8c4ed1966f --- /dev/null +++ b/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import os +import re +import time +from importlib.metadata import version +from pathlib import Path + +import cua_sandbox +import pytest +from cua_sandbox import Image, Pool, Sandbox, WarmPoolAutoscaling + +from tests.live.fleet_e2e_support import ( + assert_template_contract, + build_fleet_client, + build_pool_namespace_name, + collect_resource_inventory, + has_oauth_credentials, + is_not_found_error, + wait_claims_absent, + write_summary, +) + +IMAGE = ( + "public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04" + "@sha256:82702ebdd32d1f8fc05f2ea409a7c67d0ba9f8f8e4e9f1a89ce40989d5f4475d" +) + +POOL_CPU = 4 +POOL_MEMORY_MB = 4096 +WARM_TIME_TO_START = 180 +COLD_TIME_TO_START = 900 +WARM_BIND_SLA_SECONDS = 300 + +MODE_ENV = { + "warm": "CUA_LIVE_E2E_POOL_WARM_NAMESPACE", + "cold": "CUA_LIVE_E2E_POOL_COLD_NAMESPACE", +} + + +def cold_autoscaling() -> WarmPoolAutoscaling: + # replicas < 1 is rejected by Pool.apply, so scale-to-zero is expressed + # through autoscaling: KEDA owns spec.replicas and decays it to zero. + return WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1) + + +def selected_pool_namespace(mode: str) -> str: + lane = os.environ.get("CUA_LIVE_E2E_LANE", "local") + namespace = os.environ.get(MODE_ENV[mode]) or build_pool_namespace_name( + mode, + lane, + os.environ.get("CUA_LIVE_E2E_EVENT", os.environ.get("GITHUB_EVENT_NAME", "manual")), + ) + if not namespace.startswith(f"cua-live-pool-{mode}-"): + raise ValueError(f"{MODE_ENV[mode]} must start with cua-live-pool-{mode}-") + if len(namespace) > 63 or re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", namespace) is None: + raise ValueError(f"{MODE_ENV[mode]} must be a DNS-1123 label of at most 63 characters") + return namespace + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.skipif(not has_oauth_credentials(), reason="Fleet OAuth credentials not set"), +] + + +async def run_fleet_pool_live(mode: str) -> None: + lane = os.environ.get("CUA_LIVE_E2E_LANE", "local") + namespace = selected_pool_namespace(mode) + artifact_dir = Path(os.environ.get("CUA_LIVE_E2E_ARTIFACT_DIR", "/tmp/cua-live-e2e")) + summary = { + "lane": lane, + "mode": mode, + "namespace": namespace, + "image": IMAGE, + "source_sha": os.environ.get("CUA_LIVE_E2E_SOURCE_SHA") or os.environ.get("GITHUB_SHA"), + "packages": { + "cua-sandbox": version("cua-sandbox"), + "cua-fleet": version("cua-fleet"), + }, + "module_origins": { + "cua_sandbox": str(Path(cua_sandbox.__file__).resolve()), + }, + } + fleet, http_client = build_fleet_client() + primary_error: BaseException | None = None + cleanup_error: BaseException | None = None + close_error: BaseException | None = None + summary_error: BaseException | None = None + pool_applied = False + sandbox_yielded = False + + def record_cleanup_error(error: BaseException) -> None: + nonlocal cleanup_error + error_summary = {"type": type(error).__name__} + if cleanup_error is None: + cleanup_error = error + summary["cleanup_error"] = error_summary + else: + summary.setdefault("cleanup_secondary_errors", []).append(error_summary) + + try: + try: + existing = await Pool.get(namespace) + except BaseException as error: + if not is_not_found_error(error): + raise + summary["pool_pre_existed"] = False + else: + summary["pool_pre_existed"] = True + summary["spec_replicas_before"] = existing.resource.spec.replicas + status = getattr(existing.resource, "status", None) + summary["ready_replicas_before"] = ( + (getattr(status, "ready_replicas", None) or 0) if status is not None else 0 + ) + + apply_started = time.monotonic() + pool = await Pool.apply( + Image.from_registry(IMAGE), + name=namespace, + replicas=1, + cpu=POOL_CPU, + memory_mb=POOL_MEMORY_MB, + autoscaling=None if mode == "warm" else cold_autoscaling(), + ) + pool_applied = True + summary["apply_seconds"] = time.monotonic() - apply_started + assert pool.name == namespace, f"pool name {pool.name!r} must equal namespace {namespace!r}" + + template = await fleet.get_template(namespace, namespace) + assert_template_contract(template, expected_port=8000) + + claim_started = time.monotonic() + async with Sandbox.ephemeral( + pool=namespace, + name=namespace, + time_to_start=WARM_TIME_TO_START if mode == "warm" else COLD_TIME_TO_START, + telemetry_enabled=False, + ) as sandbox: + sandbox_yielded = True + claim_seconds = time.monotonic() - claim_started + summary["claim_seconds"] = claim_seconds + summary["sandbox_name"] = sandbox.name + sandbox_claim_name = getattr(sandbox, "claim_name", None) + sandbox_pool_name = getattr(sandbox, "pool_name", None) + summary["claim_name"] = sandbox_claim_name or sandbox.name + summary["pool_name"] = sandbox_pool_name or namespace + try: + assert ( + isinstance(sandbox.name, str) and sandbox.name + ), "sandbox name must be a non-empty string" + if sandbox_claim_name is not None: + assert sandbox_claim_name == namespace, ( + f"claim name {sandbox_claim_name!r} must equal " + f"requested name {namespace!r}" + ) + if sandbox_pool_name is not None: + assert ( + sandbox_pool_name == namespace + ), f"pool name {sandbox_pool_name!r} must equal namespace {namespace!r}" + + warm_bind_sla_applied = ( + mode == "warm" + and summary.get("pool_pre_existed") is True + and summary.get("ready_replicas_before", 0) >= 1 + ) + summary["warm_bind_sla_applied"] = warm_bind_sla_applied + if warm_bind_sla_applied: + assert claim_seconds < WARM_BIND_SLA_SECONDS, ( + f"pre-provisioned warm claim took {claim_seconds:.1f}s, " + f"expected under {WARM_BIND_SLA_SECONDS}s" + ) + + width, height = await sandbox.screen.size() + summary["screen"] = {"width": width, "height": height} + assert (width, height) == (1024, 768) + + screenshot = await sandbox.screenshot() + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / f"screen-pool-{mode}.png").write_bytes(screenshot) + assert screenshot.startswith(b"\x89PNG\r\n\x1a\n") + assert len(screenshot) > 1000 + + result = await sandbox.shell.run("uname -s") + summary["shell"] = { + "success": result.success, + "stdout": result.stdout.strip(), + "stderr": result.stderr.strip(), + } + assert result.success + assert result.stdout.strip() == "Linux" + except BaseException as error: + primary_error = error + summary["error"] = {"type": type(error).__name__} + except BaseException as error: + if primary_error is None: + if sandbox_yielded: + record_cleanup_error(error) + else: + primary_error = error + summary["error"] = {"type": type(error).__name__} + else: + summary["context_exit_error"] = {"type": type(error).__name__} + finally: + if pool_applied: + cleanup_started = time.monotonic() + claims_absent: bool | None = None + inventory: dict[str, list[str]] | None = None + try: + claims_absent = await wait_claims_absent(fleet, namespace) + summary["claims_absent"] = claims_absent + except BaseException as error: + record_cleanup_error(error) + try: + inventory = await collect_resource_inventory(fleet, namespace) + summary["persistent_resources"] = inventory + except BaseException as error: + record_cleanup_error(error) + + if claims_absent is False: + try: + summary["claim_leak"] = True + pytest.fail(f"claims remain in namespace {namespace} after claim-only release") + except BaseException as error: + record_cleanup_error(error) + if inventory is not None: + expected_inventory = { + "templates": [namespace], + "pools": [namespace], + "claims": [], + } + if inventory != expected_inventory: + try: + summary["unexpected_inventory"] = True + pytest.fail( + f"persistent pool inventory for namespace {namespace} must be " + f"{expected_inventory}, got {inventory}" + ) + except BaseException as error: + record_cleanup_error(error) + try: + refreshed = await Pool.get(namespace) + summary["spec_replicas_after"] = refreshed.resource.spec.replicas + status = getattr(refreshed.resource, "status", None) + summary["ready_replicas_after"] = ( + (getattr(status, "ready_replicas", None) or 0) if status is not None else 0 + ) + except BaseException as error: + record_cleanup_error(error) + summary["cleanup_seconds"] = time.monotonic() - cleanup_started + if not sandbox_yielded: + summary["provisioning"] = { + "pool_applied": pool_applied, + "sandbox_yielded": False, + } + + try: + await http_client.aclose() + except BaseException as error: + close_error = error + summary["close_error"] = {"type": type(error).__name__} + + try: + write_summary(artifact_dir / f"summary-pool-{mode}.json", summary) + except BaseException as error: + summary_error = error + summary["summary_error"] = {"type": type(error).__name__} + + if primary_error is not None: + raise primary_error + if cleanup_error is not None: + raise cleanup_error + if close_error is not None: + raise close_error + if summary_error is not None: + raise summary_error + + +async def test_fleet_pool_warm_live() -> None: + await run_fleet_pool_live("warm") + + +async def test_fleet_pool_cold_live() -> None: + await run_fleet_pool_live("cold") diff --git a/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py b/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py index 3055122634..6cc5f26f40 100644 --- a/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py +++ b/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py @@ -11,7 +11,9 @@ from tests.live.fleet_e2e_support import ( assert_template_contract, build_namespace_name, + build_pool_namespace_name, collect_resource_inventory, + has_oauth_credentials, wait_claims_absent, write_summary, ) @@ -49,6 +51,39 @@ def test_build_namespace_name_normalizes_invalid_overlong_lane_input() -> None: assert name.startswith("cua-live-published-package-") +@pytest.mark.parametrize("mode", ["warm", "cold"]) +@pytest.mark.parametrize( + ("event_name", "event_class"), + [("schedule", "schedule"), ("push", "push"), ("workflow_dispatch", "manual")], +) +def test_build_pool_namespace_name_is_stable_for_each_mode_lane_and_event_class( + mode: str, event_name: str, event_class: str +) -> None: + name = build_pool_namespace_name(mode, "published-package", event_name) + assert name == f"cua-live-pool-{mode}-published-package-{event_class}" + assert len(name) <= 63 + + +def test_build_pool_namespace_name_normalizes_invalid_overlong_input() -> None: + name = build_pool_namespace_name( + "warm", "Published_Package!!!" + "X" * 100, "workflow_dispatch" + ) + + assert len(name) <= 63 + assert re.fullmatch(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", name) + assert name.startswith("cua-live-pool-warm-published-package-") + + +def test_support_has_oauth_credentials_requires_both_values(monkeypatch) -> None: + monkeypatch.delenv("CUA_CLIENT_ID", raising=False) + monkeypatch.delenv("CUA_CLIENT_SECRET", raising=False) + assert not has_oauth_credentials() + monkeypatch.setenv("CUA_CLIENT_ID", "client") + assert not has_oauth_credentials() + monkeypatch.setenv("CUA_CLIENT_SECRET", "secret") + assert has_oauth_credentials() + + def test_assert_template_contract_accepts_server_port_8000() -> None: probes = SimpleNamespace( to_json=lambda: json.dumps({"readinessProbe": {"tcpSocket": {"port": 8000}}}) diff --git a/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py b/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py new file mode 100644 index 0000000000..654471b5e1 --- /dev/null +++ b/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fleet_sdk import SdkError + +from tests.live import test_fleet_pool_persistent as pool_live + +PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 2000 + + +def make_pool_resource(namespace: str, *, spec_replicas: int = 1, ready_replicas: int = 1): + return SimpleNamespace( + metadata=SimpleNamespace(namespace=namespace, name=namespace), + spec=SimpleNamespace(replicas=spec_replicas), + status=SimpleNamespace(ready_replicas=ready_replicas), + ) + + +def not_found(operation: str = "get pool") -> SdkError.Status: + return SdkError.Status(operation, 404, b"not found") + + +class FakePoolHandle: + def __init__(self, resource) -> None: + self._resource = resource + + @property + def name(self) -> str: + return self._resource.metadata.name + + @property + def resource(self): + return self._resource + + +class FakeSandbox: + def __init__(self, name: str, shell_stdout: str) -> None: + self.name = name + self.claim_name = name + self.pool_name = name + self.screen = SimpleNamespace(size=self._size) + self.shell = SimpleNamespace(run=self._run) + self._shell_stdout = shell_stdout + + async def _size(self) -> tuple[int, int]: + return (1024, 768) + + async def _run(self, command: str): + return SimpleNamespace(success=True, stdout=self._shell_stdout, stderr="") + + async def screenshot(self) -> bytes: + return PNG + + +class _FakeEphemeral: + def __init__(self, sandbox: FakeSandbox) -> None: + self._sandbox = sandbox + + async def __aenter__(self) -> FakeSandbox: + return self._sandbox + + async def __aexit__(self, exc_type, exc, traceback) -> bool: + return False + + +class PoolRunnerHarness: + def __init__(self) -> None: + self.get_results: list = [] + self.apply_calls: list[dict] = [] + self.apply_error: BaseException | None = None + self.ephemeral_calls: list[dict] = [] + self.shell_stdout = "Linux" + self.claims_absent: object = True + self.claims_absent_calls: list[str] = [] + self.inventory: object = None + self.inventory_calls: list[str] = [] + self.template_contract_calls: list[tuple] = [] + self.summaries: dict[str, dict] = {} + + +def install_pool_runner(monkeypatch, tmp_path, *, mode: str, namespace: str) -> PoolRunnerHarness: + harness = PoolRunnerHarness() + monkeypatch.setenv("CUA_LIVE_E2E_LANE", "test") + monkeypatch.setenv("CUA_LIVE_E2E_EVENT", "schedule") + monkeypatch.setenv("CUA_LIVE_E2E_ARTIFACT_DIR", str(tmp_path)) + monkeypatch.setenv(pool_live.MODE_ENV[mode], namespace) + + class FakePool: + @staticmethod + async def get(name: str) -> FakePoolHandle: + assert harness.get_results, "unexpected Pool.get call" + result = harness.get_results.pop(0) + if isinstance(result, BaseException): + raise result + return FakePoolHandle(result) + + @staticmethod + async def apply(image, **kwargs) -> FakePoolHandle: + harness.apply_calls.append({"image": image, **kwargs}) + if harness.apply_error is not None: + raise harness.apply_error + return FakePoolHandle(make_pool_resource(kwargs["name"])) + + class FakeSandboxApi: + @staticmethod + def ephemeral(**kwargs) -> _FakeEphemeral: + harness.ephemeral_calls.append(kwargs) + return _FakeEphemeral(FakeSandbox(kwargs["name"], harness.shell_stdout)) + + class FakeFleet: + async def get_template(self, template_namespace: str, name: str): + return SimpleNamespace(namespace=template_namespace, name=name) + + class FakeHttpClient: + async def aclose(self) -> None: + return None + + async def fake_wait_claims_absent(fleet, name: str) -> bool: + harness.claims_absent_calls.append(name) + if isinstance(harness.claims_absent, BaseException): + raise harness.claims_absent + return bool(harness.claims_absent) + + async def fake_collect_resource_inventory(fleet, name: str): + harness.inventory_calls.append(name) + if isinstance(harness.inventory, BaseException): + raise harness.inventory + if harness.inventory is None: + return {"templates": [name], "pools": [name], "claims": []} + return harness.inventory + + def fake_assert_template_contract(template, expected_port: int) -> None: + harness.template_contract_calls.append((template, expected_port)) + + def fake_write_summary(path, summary) -> None: + harness.summaries[path.name] = summary + + monkeypatch.setattr(pool_live, "Pool", FakePool) + monkeypatch.setattr(pool_live, "Sandbox", FakeSandboxApi) + monkeypatch.setattr(pool_live, "build_fleet_client", lambda: (FakeFleet(), FakeHttpClient())) + monkeypatch.setattr(pool_live, "wait_claims_absent", fake_wait_claims_absent) + monkeypatch.setattr(pool_live, "collect_resource_inventory", fake_collect_resource_inventory) + monkeypatch.setattr(pool_live, "assert_template_contract", fake_assert_template_contract) + monkeypatch.setattr(pool_live, "write_summary", fake_write_summary) + return harness + + +@pytest.mark.asyncio +async def test_pool_live_runner_uses_pinned_warm_configuration(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [ + make_pool_resource(namespace, ready_replicas=1), + make_pool_resource(namespace, ready_replicas=1), + ] + + await pool_live.run_fleet_pool_live("warm") + + (apply_call,) = harness.apply_calls + assert apply_call["name"] == namespace + assert apply_call["replicas"] == 1 + assert apply_call["cpu"] == 4 + assert apply_call["memory_mb"] == 4096 + assert apply_call["autoscaling"] is None + + (ephemeral_call,) = harness.ephemeral_calls + assert ephemeral_call["pool"] == namespace + assert isinstance(ephemeral_call["pool"], str) + assert ephemeral_call["name"] == namespace + assert ephemeral_call["time_to_start"] == 180 + assert ephemeral_call["telemetry_enabled"] is False + for rejected in ("image", "cpu", "memory_mb", "server_port", "replicas"): + assert rejected not in ephemeral_call + + ((_, contract_port),) = harness.template_contract_calls + assert contract_port == 8000 + summary = harness.summaries["summary-pool-warm.json"] + assert summary["pool_pre_existed"] is True + assert summary["ready_replicas_before"] == 1 + assert summary["warm_bind_sla_applied"] is True + assert summary["claims_absent"] is True + assert summary["persistent_resources"] == { + "templates": [namespace], + "pools": [namespace], + "claims": [], + } + assert summary["spec_replicas_after"] == 1 + + +@pytest.mark.asyncio +async def test_pool_live_runner_uses_pinned_cold_configuration(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-cold-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="cold", namespace=namespace) + harness.get_results = [ + make_pool_resource(namespace, ready_replicas=0), + make_pool_resource(namespace, ready_replicas=0), + ] + + await pool_live.run_fleet_pool_live("cold") + + (apply_call,) = harness.apply_calls + assert apply_call["replicas"] == 1 + autoscaling = apply_call["autoscaling"] + assert autoscaling is not None + assert autoscaling.min_pool_size == 0 + assert autoscaling.initial_pool_size == 0 + assert autoscaling.max_pool_size == 1 + + (ephemeral_call,) = harness.ephemeral_calls + assert ephemeral_call["time_to_start"] == 900 + + summary = harness.summaries["summary-pool-cold.json"] + assert summary["warm_bind_sla_applied"] is False + + +@pytest.mark.asyncio +async def test_pool_pre_existed_false_is_recorded_for_public_sdk_404(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [not_found(), make_pool_resource(namespace)] + + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["pool_pre_existed"] is False + assert "ready_replicas_before" not in summary + assert summary["warm_bind_sla_applied"] is False + + +@pytest.mark.asyncio +async def test_empty_inventory_fails_as_unexpected(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [make_pool_resource(namespace), make_pool_resource(namespace)] + harness.inventory = {"templates": [], "pools": [], "claims": []} + + with pytest.raises(pytest.fail.Exception, match="persistent pool inventory"): + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["unexpected_inventory"] is True + + +@pytest.mark.asyncio +async def test_claim_leak_fails_and_is_recorded(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [make_pool_resource(namespace), make_pool_resource(namespace)] + harness.claims_absent = False + + with pytest.raises(pytest.fail.Exception, match="claims remain"): + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["claim_leak"] is True + + +@pytest.mark.asyncio +async def test_warm_sla_not_applied_without_ready_replicas(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [ + make_pool_resource(namespace, ready_replicas=0), + make_pool_resource(namespace, ready_replicas=1), + ] + monkeypatch.setattr(pool_live, "WARM_BIND_SLA_SECONDS", -1.0) + + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["warm_bind_sla_applied"] is False + assert "error" not in summary + + +@pytest.mark.asyncio +async def test_warm_sla_enforced_with_ready_replicas(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [ + make_pool_resource(namespace, ready_replicas=1), + make_pool_resource(namespace, ready_replicas=1), + ] + monkeypatch.setattr(pool_live, "WARM_BIND_SLA_SECONDS", -1.0) + + with pytest.raises(AssertionError, match="pre-provisioned warm claim"): + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["warm_bind_sla_applied"] is True + assert summary["error"] == {"type": "AssertionError"} + + +@pytest.mark.asyncio +async def test_cleanup_failure_does_not_mask_primary_failure(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [make_pool_resource(namespace), make_pool_resource(namespace)] + harness.shell_stdout = "Darwin" + harness.inventory = RuntimeError("inventory unavailable") + + with pytest.raises(AssertionError): + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["error"] == {"type": "AssertionError"} + assert summary["cleanup_error"] == {"type": "RuntimeError"} + + +@pytest.mark.asyncio +async def test_apply_failure_skips_persistence_verification(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [not_found()] + harness.apply_error = RuntimeError("apply rejected") + + with pytest.raises(RuntimeError, match="apply rejected"): + await pool_live.run_fleet_pool_live("warm") + + assert harness.claims_absent_calls == [] + assert harness.inventory_calls == [] + assert harness.ephemeral_calls == [] + summary = harness.summaries["summary-pool-warm.json"] + assert summary["provisioning"] == {"pool_applied": False, "sandbox_yielded": False} + assert summary["error"] == {"type": "RuntimeError"} + + +def test_selected_pool_namespace_rejects_foreign_prefix(monkeypatch) -> None: + monkeypatch.setenv("CUA_LIVE_E2E_POOL_WARM_NAMESPACE", "cua-live-warm-other") + + with pytest.raises(ValueError, match="must start with cua-live-pool-warm-"): + pool_live.selected_pool_namespace("warm") + + +def test_selected_pool_namespace_requires_dns_1123_label(monkeypatch) -> None: + monkeypatch.setenv("CUA_LIVE_E2E_POOL_COLD_NAMESPACE", "cua-live-pool-cold-" + "x" * 60) + + with pytest.raises(ValueError, match="DNS-1123"): + pool_live.selected_pool_namespace("cold") + + +def test_selected_pool_namespace_builds_default_from_lane_and_event(monkeypatch) -> None: + monkeypatch.delenv("CUA_LIVE_E2E_POOL_WARM_NAMESPACE", raising=False) + monkeypatch.setenv("CUA_LIVE_E2E_LANE", "main-source") + monkeypatch.setenv("CUA_LIVE_E2E_EVENT", "schedule") + + assert pool_live.selected_pool_namespace("warm") == "cua-live-pool-warm-main-source-schedule" From ab0208242280ad83bac337ba02e00eaab0b8e2ec Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 11:24:17 -0700 Subject: [PATCH 061/117] fix(cua-sandbox): adopt upstream PoolAccessDenied via cua-fleet 0.1.12 (#3234) * fix(cua-sandbox): adopt upstream PoolAccessDenied via cua-fleet 0.1.12 With cua-fleet 0.1.12, PoolAccessDeniedError aliases SdkError.PoolAccessDenied and the native client raises it directly for 403s on pool-namespace writes. The uniffi-generated exception renders as a field dump instead of the Rust Display message, so canonicalize its args wherever it is constructed or caught, keeping the shared 'globally unique / Discord' guidance identical to the Rust SDK. Bumps the package to 0.4.1 for release. Co-Authored-By: Claude Fable 5 * style(cua-sandbox): sort fleet_cloud imports per isort Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- libs/python/cua-sandbox/cua_sandbox/pool.py | 6 +++ .../cua_sandbox/transport/fleet_cloud.py | 47 +++++++++++++++---- libs/python/cua-sandbox/pyproject.toml | 4 +- libs/python/cua-sandbox/tests/test_pool.py | 27 +++++++++++ libs/python/cua-sandbox/uv.lock | 16 +++---- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/libs/python/cua-sandbox/cua_sandbox/pool.py b/libs/python/cua-sandbox/cua_sandbox/pool.py index 482fb2aa01..e8e9e55951 100644 --- a/libs/python/cua-sandbox/cua_sandbox/pool.py +++ b/libs/python/cua-sandbox/cua_sandbox/pool.py @@ -9,7 +9,9 @@ from cua_sandbox.sandbox import Sandbox from cua_sandbox.transport.fleet import FleetTransport from cua_sandbox.transport.fleet_cloud import ( + _NATIVE_POOL_ACCESS_DENIED, FleetCloudTransport, + _canonicalize_pool_access_denied, _FleetClient, _pool_access_denied, ) @@ -76,6 +78,8 @@ async def reconcile(cls, request: CreateTemplateRequest) -> "Template": try: try: return cls(await client.reconcile_template(request)) + except _NATIVE_POOL_ACCESS_DENIED as error: + raise _canonicalize_pool_access_denied(error) except SdkError.Status as error: if error.status == 403: raise _pool_access_denied(request.namespace, error) from error @@ -223,6 +227,8 @@ async def reconcile(cls, request: CreatePoolRequest) -> "Pool": try: try: return cls(await client.reconcile_pool(request)) + except _NATIVE_POOL_ACCESS_DENIED as error: + raise _canonicalize_pool_access_denied(error) except SdkError.Status as error: if error.status == 403: raise _pool_access_denied(request.namespace, error) from error diff --git a/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py b/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py index 295073ab50..0389ce11cd 100644 --- a/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py +++ b/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py @@ -74,20 +74,45 @@ class PoolAccessDeniedError(PermissionError): # type: ignore[no-redef] """Fleet refused a pool or template operation for this credential.""" +# Catch tuple for pool-access denials raised natively by newer Fleet SDKs; +# empty when the installed cua-fleet predates the upstream variant. +_NATIVE_POOL_ACCESS_DENIED = ( + () if _UPSTREAM_POOL_ACCESS_DENIED is None else (_UPSTREAM_POOL_ACCESS_DENIED,) +) + + +def _pool_access_denied_message(operation: str, namespace: str, status: int, body: str) -> str: + return ( + f"Fleet denied {operation} on pool namespace '{namespace}' " + f"(HTTP {status}: {body}). Pool names are globally unique " + "across accounts, so this name may already be taken — try a new pool " + "name. If that does not work, contact support on Discord: " + "https://discord.gg/mVnXXpdE85" + ) + + +def _canonicalize_pool_access_denied(error: Exception) -> Exception: + # The uniffi-generated exception renders as a field dump + # (operation=..., namespace=..., ...); restore the Rust Display message so + # both raise paths read identically. + error.args = ( + _pool_access_denied_message(error.operation, error.namespace, error.status, error.body), + ) + return error + + def _pool_access_denied(namespace: str, error: SdkError.Status) -> Exception: if _UPSTREAM_POOL_ACCESS_DENIED is not None: - return _UPSTREAM_POOL_ACCESS_DENIED( - operation=error.operation, - namespace=namespace, - status=error.status, - body=error.body, + return _canonicalize_pool_access_denied( + _UPSTREAM_POOL_ACCESS_DENIED( + operation=error.operation, + namespace=namespace, + status=error.status, + body=error.body, + ) ) return PoolAccessDeniedError( - f"Fleet denied {error.operation} on pool namespace '{namespace}' " - f"(HTTP {error.status}: {error.body}). Pool names are globally unique " - "across accounts, so this name may already be taken — try a new pool " - "name. If that does not work, contact support on Discord: " - "https://discord.gg/mVnXXpdE85" + _pool_access_denied_message(error.operation, namespace, error.status, error.body) ) @@ -492,6 +517,8 @@ async def connect(self) -> None: self._template = await self._sdk.reconcile_template( self._template_request() ) + except _NATIVE_POOL_ACCESS_DENIED as error: + raise _canonicalize_pool_access_denied(error) except SdkError.Status as error: if error.status == 403: raise _pool_access_denied(self._pool_name, error) from error diff --git a/libs/python/cua-sandbox/pyproject.toml b/libs/python/cua-sandbox/pyproject.toml index da4d8d10a9..513ea75863 100644 --- a/libs/python/cua-sandbox/pyproject.toml +++ b/libs/python/cua-sandbox/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cua-sandbox" -version = "0.4.0" +version = "0.4.1" description = "CUA Sandbox — ephemeral and persistent sandboxed computer environments" readme = "README.md" license = "MIT" @@ -29,7 +29,7 @@ requires-python = ">=3.11,<3.14" dependencies = [ "cua-core>=0.3.0,<0.4.0", "cua-auto>=0.1.2", - "cua-fleet==0.1.11", + "cua-fleet==0.1.12", "websockets>=12.0", "httpx>=0.27.0", "oras>=0.2.40", diff --git a/libs/python/cua-sandbox/tests/test_pool.py b/libs/python/cua-sandbox/tests/test_pool.py index c7c29b77eb..637f5dc2f1 100644 --- a/libs/python/cua-sandbox/tests/test_pool.py +++ b/libs/python/cua-sandbox/tests/test_pool.py @@ -985,6 +985,33 @@ async def test_pool_apply_maps_forbidden_template_reconcile_and_still_rolls_back assert clients[2].deleted_pools == ["workspace"] +@pytest.mark.asyncio +async def test_pool_apply_canonicalizes_native_pool_access_denied(monkeypatch): + upstream = getattr(SdkError, "PoolAccessDenied", None) + if upstream is None: + pytest.skip("installed cua-fleet predates SdkError.PoolAccessDenied") + + native = upstream( + operation="create pool", + namespace="workspace", + status=403, + body="k8s request is not allowed", + ) + clients = [FakeFleetClient(reconcile_error=native)] + iterator = iter(clients) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(iterator)) + + with pytest.raises(PoolAccessDeniedError, match="globally unique") as error: + await Pool.apply( + Image.from_registry("registry.example/workspace:latest"), + name="workspace", + ) + + assert error.value is native + assert "Fleet denied create pool on pool namespace 'workspace'" in str(error.value) + assert "https://discord.gg/mVnXXpdE85" in str(error.value) + + @pytest.mark.asyncio async def test_pool_apply_rollback_failure_does_not_mask_template_error(monkeypatch): class DeleteDeniedClient(FakeFleetClient): diff --git a/libs/python/cua-sandbox/uv.lock b/libs/python/cua-sandbox/uv.lock index acf2294326..979e38095f 100644 --- a/libs/python/cua-sandbox/uv.lock +++ b/libs/python/cua-sandbox/uv.lock @@ -528,19 +528,19 @@ dev = [{ name = "pytest", specifier = ">=8.3.5" }] [[package]] name = "cua-fleet" -version = "0.1.11" +version = "0.1.12" source = { registry = "https://wheels.cua.ai/simple" } wheels = [ - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5884a26388b44e9cf59314d20b9aae34f278c48b108d927ebaf802b8d5b7d7f6" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f6d1532f76166fe30001c68765c4f3fcb94e9b713751f7a009d3780f523aa9c" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.11-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:c76052d9cb1710936a59709fd5a2894fe9fb43fe89e2a0313ad0b88faf4c2b1e" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.11-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:0539a40aac915368362dd2f3e90d4b6d233e7bece77d13a5733d8cecb7298731" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.11-py3-none-win_amd64.whl", hash = "sha256:da8e1c40abcd3fee5cdab48801d4bd43a277ecddb7afebcba3d9885d9ec5d431" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-win_amd64.whl", hash = "sha256:c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75" }, ] [[package]] name = "cua-sandbox" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "cua-auto" }, @@ -577,7 +577,7 @@ dev = [ requires-dist = [ { name = "cua-auto", specifier = ">=0.1.2" }, { name = "cua-core", specifier = ">=0.3.0,<0.4.0" }, - { name = "cua-fleet", specifier = "==0.1.11" }, + { name = "cua-fleet", specifier = "==0.1.12" }, { name = "grpcio", specifier = "==1.78.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "oras", specifier = ">=0.2.40" }, From 4bcf5dcc5e54b33316f313a03408d2507f93678e Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 18:30:04 +0000 Subject: [PATCH 062/117] Remove API keys section from Settings page (#7031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Reorganize settings page layout Remove the API keys section from the settings page — it duplicated the dedicated User API keys page already linked in the sidebar. Move the payment method section above the GitHub Actions OIDC config so the new order is Account, Payment method, GitHub Actions OIDC. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011i8uiCgTVRG6fFN2sLRs7o * Ignore Playwright e2e output in cyclops-cs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011i8uiCgTVRG6fFN2sLRs7o --------- Co-authored-by: Claude CloudCyclopsCs-RevId: a48fcd6da61292ca1ef33a22426ec71057a15c08 --- libs/fleet/e2e/settings.spec.ts | 19 ++++++------------- libs/fleet/src/pages/Settings.tsx | 30 ++---------------------------- 2 files changed, 8 insertions(+), 41 deletions(-) diff --git a/libs/fleet/e2e/settings.spec.ts b/libs/fleet/e2e/settings.spec.ts index c4e7f4fb18..807d2ae7a1 100644 --- a/libs/fleet/e2e/settings.spec.ts +++ b/libs/fleet/e2e/settings.spec.ts @@ -114,7 +114,7 @@ test.describe("Settings GitHub trust policies", () => { ).toHaveLength(1) }) - test("orders account, API keys, GitHub OIDC, and payment method", async ({ + test("orders account, payment method, and GitHub OIDC", async ({ page, }) => { await page.route("**/api/config", route => @@ -140,41 +140,35 @@ test.describe("Settings GitHub trust policies", () => { name: "Account", exact: true, }) - const apiKeysHeading = page.getByRole("heading", { - name: "API keys", - exact: true, - }) const githubToggle = page.getByRole("button", { name: "GitHub Actions OIDC", }) await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible() await expect(accountHeading).toBeVisible() - await expect(apiKeysHeading).toBeVisible() await expect(paymentHeading).toBeVisible() await expect(githubToggle).toBeVisible() + await expect( + page.getByRole("heading", { name: "API keys", exact: true }), + ).toHaveCount(0) const sectionOrder = await page.evaluate(() => { const elements = [...document.querySelectorAll("h1, h2, h3, h4, button")] const indexOf = (label: string) => elements.findIndex(element => element.textContent?.trim().startsWith(label)) return [ indexOf("Account"), - indexOf("API keys"), - indexOf("GitHub Actions OIDC"), indexOf("Payment method"), + indexOf("GitHub Actions OIDC"), ] }) expect(sectionOrder.every(index => index >= 0)).toBe(true) expect(sectionOrder).toEqual([...sectionOrder].sort((a, b) => a - b)) - await page.getByRole("button", { name: "Manage API keys" }).click() - await expect(page).toHaveURL(/\/user-keys$/) - await page.goto("/settings") await expect(page.getByLabel("Display name")).toBeHidden() await githubToggle.click() await expect(page.getByLabel("Display name")).toBeVisible() }) - test("keeps account and API keys usable when GitHub settings fail", async ({ page }) => { + test("keeps account usable when GitHub settings fail", async ({ page }) => { await page.unroute("**/api/github-trust-policies") await page.route("**/api/github-trust-policies", route => route.fulfill({ status: 503, body: "temporarily unavailable" }), @@ -182,7 +176,6 @@ test.describe("Settings GitHub trust policies", () => { await page.goto("/settings") await expect(page.getByRole("heading", { name: "Account" })).toBeVisible() - await expect(page.getByRole("button", { name: "Manage API keys" })).toBeVisible() await expect( page.getByText("GitHub Actions settings are unavailable. Expand to retry."), ).toBeVisible() diff --git a/libs/fleet/src/pages/Settings.tsx b/libs/fleet/src/pages/Settings.tsx index 9d118ddc9c..7155a4bf62 100644 --- a/libs/fleet/src/pages/Settings.tsx +++ b/libs/fleet/src/pages/Settings.tsx @@ -2,7 +2,6 @@ // policies for backend automation. import { useEffect, useMemo, useState } from "react" -import { useNavigate } from "react-router-dom" import Alert from "@cloudscape-design/components/alert" import Box from "@cloudscape-design/components/box" import Container from "@cloudscape-design/components/container" @@ -26,7 +25,6 @@ import { useFeatureFlags } from "../components/FeatureFlagContext" import { useFlash } from "../components/FlashContext" import { PageEmpty, PageError } from "../components/PageState" import { PageShell } from "../components/PageShell" -import { localVisualPreviewPath } from "../local-visual-preview" import { type GitHubTrustPolicy, githubTrustPoliciesApi, @@ -35,7 +33,6 @@ import { import { BillingSettings } from "./Billing" export function Settings() { - const navigate = useNavigate() const flash = useFlash() const { sub, name } = userInfo() const { billing } = useFeatureFlags() @@ -245,7 +242,7 @@ steps: - navigate(localVisualPreviewPath("/user-keys"))} - > - Manage API keys - - } - > - API keys - - } - > - - Create scoped credentials for local tools, CI workflows, and other - automation that calls Cua on your behalf. - - + {billing && } - {billing && } - {confirmDelete && ( Date: Mon, 17 Aug 2026 12:04:12 -0700 Subject: [PATCH 063/117] test(cua-sandbox): tolerate Fleet 403 for not-yet-created pool namespaces (#3235) The persistent pool suite's first scheduled runs failed at the observe step: Pool.get on a pool namespace that has not been created yet returns 403, not 404, because Fleet evaluates authorization before existence. The SDK's own reconcile_pool already treats 403 and 404 identically by creating the pool, so add is_pool_missing_error (403|404) to the live support module and use it for the observe step. A genuine access denial still fails the run at Pool.apply with the canonical PoolAccessDenied guidance from cua-fleet 0.1.12. Also align two latent scripts-suite drifts exposed by this branch, both from merges that did not run the suite: pin the cua-fleet release wiring test to the 0.1.12 wheel set now promoted by cd-py-fleet.yml, and track the 0.4.1 cua-sandbox version in .bumpversion.cfg. Claude-Session: https://claude.ai/code/session_01RZC4ofqRTQ52tH9TTRQwKc Co-authored-by: Claude Fable 5 --- .../tests/test_cua_fleet_release_wiring.py | 13 ++++--- .../tests/test_periodic_cua_sandbox_live.py | 1 + ...026-08-09-periodic-cua-sandbox-live-e2e.md | 7 ++-- ...09-periodic-cua-sandbox-live-e2e-design.md | 7 +++- libs/python/cua-sandbox/.bumpversion.cfg | 2 +- .../tests/live/fleet_e2e_support.py | 7 ++++ .../tests/live/test_fleet_pool_persistent.py | 4 +-- .../tests/test_live_fleet_e2e_support.py | 16 +++++++++ .../tests/test_live_fleet_pool_support.py | 36 +++++++++++++++++++ 9 files changed, 80 insertions(+), 13 deletions(-) diff --git a/.github/scripts/tests/test_cua_fleet_release_wiring.py b/.github/scripts/tests/test_cua_fleet_release_wiring.py index 72e4bfdc10..1add9bf2ab 100644 --- a/.github/scripts/tests/test_cua_fleet_release_wiring.py +++ b/.github/scripts/tests/test_cua_fleet_release_wiring.py @@ -3,21 +3,20 @@ from pathlib import Path import unittest - REPO_ROOT = Path(__file__).resolve().parents[3] class TestCuaFleetReleaseWiring(unittest.TestCase): """Keep Fleet's promotion workflow aligned with canonical SDK wheels.""" - def test_publisher_promotes_cua_fleet_0_1_11(self) -> None: + def test_publisher_promotes_cua_fleet_0_1_12(self) -> None: workflow = (REPO_ROOT / ".github/workflows/cd-py-fleet.yml").read_text() expected_sources = { - "cua_fleet-0.1.11-py3-none-manylinux_2_34_x86_64.whl": "0539a40aac915368362dd2f3e90d4b6d233e7bece77d13a5733d8cecb7298731", - "cua_fleet-0.1.11-py3-none-manylinux_2_34_aarch64.whl": "c76052d9cb1710936a59709fd5a2894fe9fb43fe89e2a0313ad0b88faf4c2b1e", - "cua_fleet-0.1.11-py3-none-macosx_10_12_x86_64.whl": "5884a26388b44e9cf59314d20b9aae34f278c48b108d927ebaf802b8d5b7d7f6", - "cua_fleet-0.1.11-py3-none-macosx_11_0_arm64.whl": "1f6d1532f76166fe30001c68765c4f3fcb94e9b713751f7a009d3780f523aa9c", - "cua_fleet-0.1.11-py3-none-win_amd64.whl": "da8e1c40abcd3fee5cdab48801d4bd43a277ecddb7afebcba3d9885d9ec5d431", + "cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl": "d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a", + "cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl": "0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973", + "cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl": "d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75", + "cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl": "82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2", + "cua_fleet-0.1.12-py3-none-win_amd64.whl": "c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75", } self.assertIn("https://wheels.cua.ai/simple/cua-fleet/$WHEEL", workflow) diff --git a/.github/scripts/tests/test_periodic_cua_sandbox_live.py b/.github/scripts/tests/test_periodic_cua_sandbox_live.py index 316ab91512..c2dbd6947e 100644 --- a/.github/scripts/tests/test_periodic_cua_sandbox_live.py +++ b/.github/scripts/tests/test_periodic_cua_sandbox_live.py @@ -370,6 +370,7 @@ def test_pool_suite_is_claim_only_and_pool_is_persistent(self) -> None: self.assertIn("unexpected_inventory", pool_test) self.assertIn("module_origins", pool_test) self.assertIn("pool_pre_existed", pool_test) + self.assertIn("is_pool_missing_error", pool_test) self.assertNotIn("pool.delete(", pool_test) self.assertNotIn("delete_pool", pool_test) self.assertNotIn("delete_namespace", pool_test) diff --git a/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md b/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md index 84d286adda..b8b3d58e51 100644 --- a/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md +++ b/docs/superpowers/plans/2026-08-09-periodic-cua-sandbox-live-e2e.md @@ -930,8 +930,11 @@ The warm pool keeps `replicas=1` so claims bind to pre-provisioned capacity; the cold pool expresses scale-to-zero with `WarmPoolAutoscaling(min_pool_size=0, initial_pool_size=0, max_pool_size=1)` because pool reconciliation rejects `replicas` below one. Each run records -`pool_pre_existed` and replica counts from `Pool.get`, reconciles the pinned -configuration idempotently with `Pool.apply`, claims through +`pool_pre_existed` and replica counts from `Pool.get`, treating both 403 and +404 as not-pre-existed (`is_pool_missing_error`) because Fleet evaluates +authorization before existence for namespaces that have not been created +yet, reconciles the pinned configuration idempotently with `Pool.apply`, +claims through `Sandbox.ephemeral(pool=..., name=...)` with the claim name fixed to the namespace, and exits with a claim-only release. After release the monitor polls until claims are absent and requires the reconciled inventory to contain diff --git a/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md b/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md index 35d6240620..6062fbe043 100644 --- a/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md +++ b/docs/superpowers/specs/2026-08-09-periodic-cua-sandbox-live-e2e-design.md @@ -203,7 +203,12 @@ cold-starts capacity through autoscaler demand. Each run observes the pool first with `Pool.get`, recording `pool_pre_existed` and the replica counts, then reconciles the pinned configuration with `Pool.apply` using the same certified image digest, -`cpu=4`, and `memory_mb=4096`. Reconciliation is idempotent: it bootstraps a +`cpu=4`, and `memory_mb=4096`. Fleet evaluates authorization before +existence, so reading a pool in a namespace that has not been created yet +returns 403 rather than 404; the observe step treats both statuses as +not-pre-existed (`is_pool_missing_error`), mirroring the SDK's reconcile +semantics, and a genuine access denial still fails the run at `Pool.apply` +with the canonical `PoolAccessDenied` guidance. Reconciliation is idempotent: it bootstraps a missing pool, heals drift, and never deletes. The claim uses `Sandbox.ephemeral(pool=..., name=...)` with the claim name fixed to the namespace, so an interrupted run's claim is adopted and released by the next diff --git a/libs/python/cua-sandbox/.bumpversion.cfg b/libs/python/cua-sandbox/.bumpversion.cfg index 238bca270f..b9f99a3bd1 100644 --- a/libs/python/cua-sandbox/.bumpversion.cfg +++ b/libs/python/cua-sandbox/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.3.6 +current_version = 0.4.1 commit = True tag = True tag_name = sandbox-v{new_version} diff --git a/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py b/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py index b60320fa91..b2b730eae9 100644 --- a/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py +++ b/libs/python/cua-sandbox/tests/live/fleet_e2e_support.py @@ -98,6 +98,13 @@ def is_not_found_error(error: BaseException) -> bool: return isinstance(error, SdkError.Status) and error.status == 404 +def is_pool_missing_error(error: BaseException) -> bool: + # Fleet evaluates RBAC before existence, so reading a pool in a namespace + # that has not been created yet returns 403 rather than 404. Mirror the + # SDK's reconcile semantics, which create the pool on either status. + return isinstance(error, SdkError.Status) and error.status in (403, 404) + + async def wait_claims_absent( client: CyclopsClient, name: str, diff --git a/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py b/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py index 8c4ed1966f..6c63803ac9 100644 --- a/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py +++ b/libs/python/cua-sandbox/tests/live/test_fleet_pool_persistent.py @@ -16,7 +16,7 @@ build_pool_namespace_name, collect_resource_inventory, has_oauth_credentials, - is_not_found_error, + is_pool_missing_error, wait_claims_absent, write_summary, ) @@ -103,7 +103,7 @@ def record_cleanup_error(error: BaseException) -> None: try: existing = await Pool.get(namespace) except BaseException as error: - if not is_not_found_error(error): + if not is_pool_missing_error(error): raise summary["pool_pre_existed"] = False else: diff --git a/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py b/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py index 6cc5f26f40..80157d0a5f 100644 --- a/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py +++ b/libs/python/cua-sandbox/tests/test_live_fleet_e2e_support.py @@ -14,6 +14,7 @@ build_pool_namespace_name, collect_resource_inventory, has_oauth_credentials, + is_pool_missing_error, wait_claims_absent, write_summary, ) @@ -74,6 +75,21 @@ def test_build_pool_namespace_name_normalizes_invalid_overlong_input() -> None: assert name.startswith("cua-live-pool-warm-published-package-") +@pytest.mark.parametrize( + ("error", "missing"), + [ + (SdkError.Status("get pool", 404, b"not found"), True), + (SdkError.Status("get pool", 403, b"forbidden"), True), + (SdkError.Status("get pool", 500, b"failure"), False), + (RuntimeError("transport down"), False), + ], +) +def test_is_pool_missing_error_mirrors_reconcile_semantics( + error: BaseException, missing: bool +) -> None: + assert is_pool_missing_error(error) is missing + + def test_support_has_oauth_credentials_requires_both_values(monkeypatch) -> None: monkeypatch.delenv("CUA_CLIENT_ID", raising=False) monkeypatch.delenv("CUA_CLIENT_SECRET", raising=False) diff --git a/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py b/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py index 654471b5e1..eeddae0fd4 100644 --- a/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py +++ b/libs/python/cua-sandbox/tests/test_live_fleet_pool_support.py @@ -22,6 +22,12 @@ def not_found(operation: str = "get pool") -> SdkError.Status: return SdkError.Status(operation, 404, b"not found") +def forbidden(operation: str = "get pool") -> SdkError.Status: + # Fleet returns 403 for pool reads in namespaces that have not been + # created yet, because RBAC is evaluated before existence. + return SdkError.Status(operation, 403, b"forbidden") + + class FakePoolHandle: def __init__(self, resource) -> None: self._resource = resource @@ -229,6 +235,36 @@ async def test_pool_pre_existed_false_is_recorded_for_public_sdk_404(monkeypatch assert summary["warm_bind_sla_applied"] is False +@pytest.mark.asyncio +async def test_pool_pre_existed_false_is_recorded_for_uncreated_namespace_403( + monkeypatch, tmp_path +) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [forbidden(), make_pool_resource(namespace)] + + await pool_live.run_fleet_pool_live("warm") + + summary = harness.summaries["summary-pool-warm.json"] + assert summary["pool_pre_existed"] is False + assert "ready_replicas_before" not in summary + assert summary["warm_bind_sla_applied"] is False + + +@pytest.mark.asyncio +async def test_observe_propagates_non_missing_pool_errors(monkeypatch, tmp_path) -> None: + namespace = "cua-live-pool-warm-test-schedule" + harness = install_pool_runner(monkeypatch, tmp_path, mode="warm", namespace=namespace) + harness.get_results = [SdkError.Status("get pool", 500, b"failure")] + + with pytest.raises(SdkError.Status): + await pool_live.run_fleet_pool_live("warm") + + assert harness.apply_calls == [] + summary = harness.summaries["summary-pool-warm.json"] + assert summary["error"] == {"type": "Status"} + + @pytest.mark.asyncio async def test_empty_inventory_fails_as_unexpected(monkeypatch, tmp_path) -> None: namespace = "cua-live-pool-warm-test-schedule" From 4c12f5eca1b8bc199f746f774928b52be0ff1aed Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 19:17:53 +0000 Subject: [PATCH 064/117] fix(auth): restore Keycloak device authorization (#7035) * fix(auth): redirect device flow to Keycloak * fix(auth): restore Keycloak device discovery URI CloudCyclopsCs-RevId: ed6e0e5fbf461e6f8edf4cb6e5c2d29c23298d97 --- libs/fleet/e2e/device.spec.ts | 38 ---- libs/fleet/nginx.conf | 4 + libs/fleet/src/main.tsx | 13 +- libs/fleet/src/pages/DeviceAuthorization.css | 210 ------------------- libs/fleet/src/pages/DeviceAuthorization.tsx | 86 -------- 5 files changed, 7 insertions(+), 344 deletions(-) delete mode 100644 libs/fleet/e2e/device.spec.ts delete mode 100644 libs/fleet/src/pages/DeviceAuthorization.css delete mode 100644 libs/fleet/src/pages/DeviceAuthorization.tsx diff --git a/libs/fleet/e2e/device.spec.ts b/libs/fleet/e2e/device.spec.ts deleted file mode 100644 index 2adcd94457..0000000000 --- a/libs/fleet/e2e/device.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from "@playwright/test" - -test.describe("CLI device authorization", () => { - test("continues sign-in and signup from a normalized user code", async ({ page }) => { - await page.goto("/device?user_code=abcd-efgh") - - await expect( - page.getByRole("heading", { name: "Connect your Cua CLI" }), - ).toBeVisible() - await expect(page.getByText("ABCD-EFGH", { exact: true })).toBeVisible() - - const signIn = page.getByRole("link", { name: "Sign in and continue" }) - await expect(signIn).toHaveAttribute( - "href", - "https://auth.cua.ai/realms/cyclops-cs/device?user_code=ABCD-EFGH", - ) - - const signUp = page.getByRole("link", { name: "Create an account" }) - const signUpHref = await signUp.getAttribute("href") - expect(signUpHref).toBe( - "https://cua.ai/signup?redirect_url=%2Fdevice%3Fuser_code%3DABCD-EFGH", - ) - }) - - test("rejects missing or malformed user codes", async ({ page }) => { - await page.goto("/device?user_code=not-a-code") - - await expect(page.getByRole("alert")).toContainText( - "Enter the code shown by the CLI", - ) - await expect( - page.getByRole("link", { name: "Sign in and continue" }), - ).toHaveCount(0) - await expect( - page.getByRole("link", { name: "Create an account" }), - ).toHaveCount(0) - }) -}) diff --git a/libs/fleet/nginx.conf b/libs/fleet/nginx.conf index bcf77dcbb9..9f2528448f 100644 --- a/libs/fleet/nginx.conf +++ b/libs/fleet/nginx.conf @@ -28,6 +28,10 @@ server { root /usr/share/nginx/html; index index.html; + location = /device { + return 302 https://auth.cua.ai/realms/cyclops-cs/device?user_code=$arg_user_code; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/libs/fleet/src/main.tsx b/libs/fleet/src/main.tsx index 0af9f9ca7f..8cbc1d8f4d 100644 --- a/libs/fleet/src/main.tsx +++ b/libs/fleet/src/main.tsx @@ -9,7 +9,6 @@ import monoFont from "@cua/design/assets/fonts/jetbrains-mono-normal-latin.woff2 import displayFont from "@cua/design/assets/fonts/instrument-serif-normal-latin.woff2" import { App } from "./App" import { AuthProvider } from "./auth/AuthProvider" -import { DeviceAuthorization } from "./pages/DeviceAuthorization" import "./shell.css" applyMode(Mode.Dark) @@ -54,16 +53,10 @@ for (const href of [urbanistFont, monoFont, displayFont]) { document.head.appendChild(link) } -const isDeviceAuthorization = window.location.pathname === "/device" - ReactDOM.createRoot(document.getElementById("root")!).render( - {isDeviceAuthorization ? ( - - ) : ( - - - - )} + + + , ) diff --git a/libs/fleet/src/pages/DeviceAuthorization.css b/libs/fleet/src/pages/DeviceAuthorization.css deleted file mode 100644 index fd848bea6a..0000000000 --- a/libs/fleet/src/pages/DeviceAuthorization.css +++ /dev/null @@ -1,210 +0,0 @@ -.device-page { - --device-ink: #15231d; - --device-muted: #66736d; - --device-cream: #f4f0e6; - --device-paper: rgba(255, 253, 247, 0.92); - --device-green: #167653; - --device-green-dark: #0c573c; - min-height: 100vh; - display: grid; - place-items: center; - overflow: hidden; - position: relative; - padding: 32px 20px; - color: var(--device-ink); - background: - radial-gradient(circle at 15% 15%, rgba(211, 228, 190, 0.9), transparent 34%), - radial-gradient(circle at 86% 82%, rgba(245, 194, 139, 0.72), transparent 30%), - linear-gradient(135deg, #e7eee3 0%, var(--device-cream) 52%, #f1e3d2 100%); - font-family: Georgia, "Times New Roman", serif; -} - -.device-page::before { - content: ""; - position: absolute; - inset: 0; - opacity: 0.28; - background-image: linear-gradient(rgba(21, 35, 29, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(21, 35, 29, 0.08) 1px, transparent 1px); - background-size: 32px 32px; - mask-image: linear-gradient(to bottom, black, transparent 88%); -} - -.device-orbit { - position: absolute; - border: 1px solid rgba(22, 118, 83, 0.22); - border-radius: 50%; - pointer-events: none; -} - -.device-orbit-one { - width: 440px; - height: 440px; - top: -220px; - right: -100px; -} - -.device-orbit-two { - width: 300px; - height: 300px; - bottom: -150px; - left: -80px; -} - -.device-card { - width: min(100%, 620px); - position: relative; - z-index: 1; - padding: clamp(32px, 6vw, 64px); - border: 1px solid rgba(21, 35, 29, 0.14); - border-radius: 28px 28px 88px 28px; - background: var(--device-paper); - box-shadow: 0 32px 90px rgba(46, 62, 51, 0.18); - backdrop-filter: blur(14px); - animation: device-rise 480ms ease-out both; -} - -.device-wordmark { - font: 800 29px/1.1 ui-rounded, "Arial Rounded MT Bold", sans-serif; - letter-spacing: -0.08em; - color: var(--device-green); - margin-bottom: 42px; -} - -.device-eyebrow { - margin: 0 0 12px; - color: var(--device-green); - font: 700 12px/1.2 ui-monospace, "SFMono-Regular", Consolas, monospace; - letter-spacing: 0.14em; - text-transform: uppercase; -} - -.device-card h1 { - margin: 0; - max-width: 470px; - font-size: clamp(38px, 8vw, 64px); - line-height: 0.96; - letter-spacing: -0.045em; - font-weight: 500; -} - -.device-lede { - max-width: 500px; - margin: 24px 0 30px; - color: var(--device-muted); - font: 400 17px/1.65 ui-sans-serif, "Avenir Next", "Segoe UI", sans-serif; -} - -.device-code { - display: inline-block; - padding: 16px 20px; - border: 1px solid rgba(21, 35, 29, 0.16); - border-radius: 12px; - color: var(--device-ink); - background: rgba(236, 241, 231, 0.78); - font: 750 clamp(25px, 7vw, 34px)/1 ui-monospace, "SFMono-Regular", Consolas, monospace; - letter-spacing: 0.16em; - box-shadow: inset 0 -3px 0 rgba(22, 118, 83, 0.12); -} - -.device-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - margin-top: 28px; -} - -.device-button { - display: inline-flex; - min-height: 50px; - align-items: center; - justify-content: center; - padding: 0 18px; - border-radius: 999px; - text-decoration: none; - font: 700 14px/1 ui-sans-serif, "Avenir Next", "Segoe UI", sans-serif; - transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; -} - -.device-button:hover { - transform: translateY(-2px); -} - -.device-button:focus-visible { - outline: 3px solid rgba(22, 118, 83, 0.3); - outline-offset: 3px; -} - -.device-button-primary { - color: white; - background: var(--device-green); - box-shadow: 0 12px 30px rgba(22, 118, 83, 0.24); -} - -.device-button-primary:hover { - background: var(--device-green-dark); -} - -.device-button-secondary { - color: var(--device-ink); - border: 1px solid rgba(21, 35, 29, 0.2); - background: rgba(255, 255, 255, 0.55); -} - -.device-footnote { - margin: 20px 0 0; - color: var(--device-muted); - font: 400 13px/1.5 ui-sans-serif, "Avenir Next", "Segoe UI", sans-serif; -} - -.device-alert { - display: grid; - gap: 8px; - padding: 18px 20px; - border-left: 4px solid #b75b35; - border-radius: 8px; - color: #522d20; - background: rgba(252, 230, 214, 0.8); - font: 400 15px/1.5 ui-sans-serif, "Avenir Next", "Segoe UI", sans-serif; -} - -@keyframes device-rise { - from { - opacity: 0; - transform: translateY(18px) scale(0.985); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@media (max-width: 600px) { - .device-page { - align-items: start; - padding: 18px 12px; - } - - .device-card { - padding: 30px 24px 38px; - border-radius: 22px 22px 58px 22px; - } - - .device-wordmark { - margin-bottom: 34px; - } - - .device-actions { - grid-template-columns: 1fr; - } -} - -@media (prefers-reduced-motion: reduce) { - .device-card { - animation: none; - } - - .device-button { - transition: none; - } -} diff --git a/libs/fleet/src/pages/DeviceAuthorization.tsx b/libs/fleet/src/pages/DeviceAuthorization.tsx deleted file mode 100644 index 3f68d6e02d..0000000000 --- a/libs/fleet/src/pages/DeviceAuthorization.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import "./DeviceAuthorization.css" - -const USER_CODE_PATTERN = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/ - -function normalizeUserCode(value: string | null): string | null { - if (!value) return null - const normalized = value.trim().toUpperCase() - return USER_CODE_PATTERN.test(normalized) ? normalized : null -} - -function keycloakBaseUrl(): string { - return window.__CYCLOPS_CS_CFG__?.kcUrl ?? "https://auth.cua.ai" -} - -function keycloakRealm(): string { - return window.__CYCLOPS_CS_CFG__?.kcRealm ?? "cyclops-cs" -} - -function deviceVerificationUrl(userCode: string): string { - const base = keycloakBaseUrl().replace(/\/$/, "") - const realm = encodeURIComponent(keycloakRealm()) - return `${base}/realms/${realm}/device?user_code=${encodeURIComponent(userCode)}` -} - -function signupUrl(userCode: string): string { - const continuation = `/device?user_code=${encodeURIComponent(userCode)}` - return `https://cua.ai/signup?redirect_url=${encodeURIComponent(continuation)}` -} - -export function DeviceAuthorization() { - const userCode = normalizeUserCode( - new URLSearchParams(window.location.search).get("user_code"), - ) - - return ( -
-
-
-
-
- cua -
-

Secure device authorization

-

Connect your Cua CLI

-

- Confirm the code from your terminal, then sign in to authorize this - device. Your CLI never receives your password. -

- - {userCode ? ( - <> -
- {userCode} -
- -

- Only continue if this code exactly matches the one shown by the - CLI. Codes expire automatically. -

- - ) : ( -
- Enter the code shown by the CLI. - - Open the complete verification link printed in your terminal, - including its eight-character code. - -
- )} -
-
- ) -} From 06619382aefce70144d890c3c092cb36860f251d Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 22:16:25 +0000 Subject: [PATCH 065/117] fix(fleet): default claim bind deadline to 900s (#7039) CloudCyclopsCs-RevId: c71f11815393d4f530039d1580dfd410f7517ec6 --- libs/fleet/sdk-bindings/README.md | 2 +- libs/fleet/sdk-schema/src/claim.rs | 4 +- libs/fleet/sdk-schema/src/lib.rs | 4 +- libs/fleet/sdk-schema/tests/claim_crds.rs | 16 ++++++ libs/fleet/sdk/src/claims.rs | 9 ++-- libs/fleet/sdk/tests/claim_flow.rs | 60 ++++++++++++++++++++++- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/libs/fleet/sdk-bindings/README.md b/libs/fleet/sdk-bindings/README.md index 23ac169a7d..56ae860968 100644 --- a/libs/fleet/sdk-bindings/README.md +++ b/libs/fleet/sdk-bindings/README.md @@ -234,7 +234,7 @@ pool = await client.create_pool(CreatePoolRequest(namespace="default", spec=pool claim_spec = ClaimSpec( sandbox_template_ref=SandboxTemplateRef(name=pool.metadata.name), warmpool=None, - bind_deadline=None, + bind_deadline=None, # SDK defaults omitted deadlines to 900 seconds lifecycle=None, ) claim = await client.create_claim(CreateClaimRequest(pool=pool, spec=claim_spec)) diff --git a/libs/fleet/sdk-schema/src/claim.rs b/libs/fleet/sdk-schema/src/claim.rs index 47fb0c9fdd..e7cc045cfa 100644 --- a/libs/fleet/sdk-schema/src/claim.rs +++ b/libs/fleet/sdk-schema/src/claim.rs @@ -6,6 +6,8 @@ use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +pub const DEFAULT_CLAIM_BIND_DEADLINE_SECONDS: u32 = 900; + fn default_warmpool() -> Option { Some("default".into()) } @@ -174,7 +176,7 @@ pub struct ClaimSpec { default, schema_with = "integer_schema", range(min = 0), - description = "Seconds the claim stays Pending waiting for a Sandbox to\nbind before it is marked Failed (default 300, set by the\noperator's OSGYM_CLAIM_BIND_DEADLINE). A Pending claim is\nthe autoscaler's demand signal, so the claim is kept\nPending across a cold VM boot + KEDA scale-up rather than\nfailing fast. Raise it for pools with slow cold starts.\n" + description = "Seconds the claim stays Pending waiting for a Sandbox to\nbind before it is marked Failed (default 900, set by the\noperator's OSGYM_CLAIM_BIND_DEADLINE). A Pending claim is\nthe autoscaler's demand signal, so the claim is kept\nPending across a cold VM boot + KEDA scale-up rather than\nfailing fast. Override it for pools with different cold-start\nrequirements.\n" )] #[serde(skip_serializing_if = "Option::is_none")] pub bind_deadline: Option, diff --git a/libs/fleet/sdk-schema/src/lib.rs b/libs/fleet/sdk-schema/src/lib.rs index 1d69ff8db0..0183356451 100644 --- a/libs/fleet/sdk-schema/src/lib.rs +++ b/libs/fleet/sdk-schema/src/lib.rs @@ -6,8 +6,8 @@ mod sandbox; mod warmpool; pub use claim::{ - ClaimLifecycle, ClaimSpec, OSGymSandboxClaim, OSGymSandboxClaimCondition, - OSGymSandboxClaimSandbox, OSGymSandboxClaimStatus, + ClaimLifecycle, ClaimSpec, DEFAULT_CLAIM_BIND_DEADLINE_SECONDS, OSGymSandboxClaim, + OSGymSandboxClaimCondition, OSGymSandboxClaimSandbox, OSGymSandboxClaimStatus, }; pub use common::{ Firmware, ImagePullPolicy, OidcConfig, RuntimeKind, SandboxService, SandboxServiceBuilder, diff --git a/libs/fleet/sdk-schema/tests/claim_crds.rs b/libs/fleet/sdk-schema/tests/claim_crds.rs index 703967c892..5e7e4f87fd 100644 --- a/libs/fleet/sdk-schema/tests/claim_crds.rs +++ b/libs/fleet/sdk-schema/tests/claim_crds.rs @@ -107,6 +107,22 @@ fn warm_pool_raw_crd_matches_the_authoritative_field_contract() { assert_eq!(generated, authoritative); } +#[test] +fn claim_bind_deadline_schema_documents_900_second_default_without_materializing_it() { + let claim = serde_json::to_value(OSGymSandboxClaim::crd()).unwrap(); + let bind_deadline = claim + .pointer("/spec/versions/0/schema/openAPIV3Schema/properties/spec/properties/bindDeadline") + .unwrap(); + + assert!(bind_deadline.get("default").is_none()); + assert!( + bind_deadline["description"] + .as_str() + .unwrap() + .contains("default 900") + ); +} + #[test] fn claim_raw_crd_matches_the_authoritative_field_contract() { let generated = normalize_known_kube_derive_artifacts( diff --git a/libs/fleet/sdk/src/claims.rs b/libs/fleet/sdk/src/claims.rs index 6b835a8b0b..d584d19ef9 100644 --- a/libs/fleet/sdk/src/claims.rs +++ b/libs/fleet/sdk/src/claims.rs @@ -2,7 +2,7 @@ use crate::{ Claim, CreateClaimRequest, CyclopsClient, HttpHeader, HttpRequest, HttpResponse, Pool, ResourceMetadata, Sandbox, SdkError, Template, routes, }; -use cyclops_sdk_schema::ClaimSpec; +use cyclops_sdk_schema::{ClaimSpec, DEFAULT_CLAIM_BIND_DEADLINE_SECONDS}; #[cfg(not(target_arch = "wasm32"))] use futures_timer::Delay; #[cfg(target_arch = "wasm32")] @@ -40,12 +40,15 @@ impl CyclopsClient { // a naming convention. A hand-built ref that names a nonexistent // template makes the bind queue lookup miss forever and the claim // times out with no useful error (the hermes-cua-pool incident). - let spec = request.spec.unwrap_or_else(|| ClaimSpec { + let mut spec = request.spec.unwrap_or_else(|| ClaimSpec { sandbox_template_ref: pool.spec.sandbox_template_ref.clone(), warmpool: None, - bind_deadline: None, + bind_deadline: Some(DEFAULT_CLAIM_BIND_DEADLINE_SECONDS), lifecycle: None, }); + if spec.bind_deadline.is_none() { + spec.bind_deadline = Some(DEFAULT_CLAIM_BIND_DEADLINE_SECONDS); + } if spec.sandbox_template_ref.name.is_empty() { return Err(SdkError::Configuration { reason: "sandbox template name must not be empty".into(), diff --git a/libs/fleet/sdk/tests/claim_flow.rs b/libs/fleet/sdk/tests/claim_flow.rs index 16fc21e56e..3937055bdf 100644 --- a/libs/fleet/sdk/tests/claim_flow.rs +++ b/libs/fleet/sdk/tests/claim_flow.rs @@ -4,7 +4,7 @@ use cyclops_sdk::{ Claim, CreateClaimRequest, CyclopsClient, CyclopsConfiguration, CyclopsCredentials, HttpHeader, HttpResponse, Pool, ResourceMetadata, SdkError, Template, }; -use cyclops_sdk_schema::ClaimSpec; +use cyclops_sdk_schema::{ClaimSpec, DEFAULT_CLAIM_BIND_DEADLINE_SECONDS}; use std::sync::Arc; use support::ScriptedHttpClient; @@ -45,6 +45,58 @@ async fn creates_pending_demand_immediately_for_a_nonzero_unavailable_pool() { assert_claim_post(&requests[0], &spec); } +#[tokio::test] +async fn create_claim_defaults_missing_bind_deadline_to_900_seconds() { + let expected = claim("claim-1", claim_spec("example-pool-template"), None); + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(json_response(201, &expected)), + ])); + + client(Arc::clone(&http), 2, 2) + .create_claim(CreateClaimRequest { + pool: pool(1), + spec: Some({ + let mut spec = claim_spec("example-pool-template"); + spec.bind_deadline = None; + spec + }), + name: None, + }) + .await + .unwrap(); + + let requests = http.authenticated_requests().await; + let body: serde_json::Value = + serde_json::from_slice(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["spec"]["bindDeadline"], 900); +} + +#[tokio::test] +async fn create_claim_preserves_explicit_bind_deadline() { + let mut spec = claim_spec("example-pool-template"); + spec.bind_deadline = Some(123); + let expected = claim("claim-1", spec.clone(), None); + let http = Arc::new(ScriptedHttpClient::new([ + Ok(token()), + Ok(json_response(201, &expected)), + ])); + + client(Arc::clone(&http), 2, 2) + .create_claim(CreateClaimRequest { + pool: pool(1), + spec: Some(spec), + name: None, + }) + .await + .unwrap(); + + let requests = http.authenticated_requests().await; + let body: serde_json::Value = + serde_json::from_slice(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["spec"]["bindDeadline"], 123); +} + #[tokio::test] async fn zero_and_nonzero_pools_post_a_single_claim_create() { for replicas in [0, 1] { @@ -471,9 +523,13 @@ fn assert_claim_post(request: &cyclops_sdk::HttpRequest, spec: &ClaimSpec) { assert_eq!(body.api_version, "osgym.cua.ai/v1alpha1"); assert_eq!(body.kind, "OSGymSandboxClaim"); assert_eq!(body.metadata.namespace, NAMESPACE); + let mut expected_spec = spec.clone(); + if expected_spec.bind_deadline.is_none() { + expected_spec.bind_deadline = Some(DEFAULT_CLAIM_BIND_DEADLINE_SECONDS); + } assert_eq!( serde_json::to_value(&body.spec).unwrap(), - serde_json::to_value(spec).unwrap() + serde_json::to_value(expected_spec).unwrap() ); let name = &body.metadata.name; assert!(name.starts_with("claim-"), "unexpected claim name {name:?}"); From a7ba40396fb07e66b7f2f3e2821fecc29d4877a4 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 22:28:34 +0000 Subject: [PATCH 066/117] test(fleet): expect default bind deadline in SDK requests (#7040) CloudCyclopsCs-RevId: e705e7e10c5feb7dd0ff6f6b7d79462255c0e5bb --- libs/fleet/sdk-bindings/examples/kotlin/AppControlled.kt | 2 +- libs/fleet/sdk-bindings/examples/ruby/app_controlled.rb | 2 +- libs/fleet/sdk-bindings/kotlin/tests/TestAsyncClient.kt | 2 +- libs/fleet/sdk-bindings/python/contract_fixture.py | 2 +- libs/fleet/sdk-bindings/ruby/tests/test_async_client.rb | 2 +- libs/fleet/sdk-bindings/swift/tests/TestAsyncClient.swift | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/fleet/sdk-bindings/examples/kotlin/AppControlled.kt b/libs/fleet/sdk-bindings/examples/kotlin/AppControlled.kt index 173456a93b..46ee2cb467 100644 --- a/libs/fleet/sdk-bindings/examples/kotlin/AppControlled.kt +++ b/libs/fleet/sdk-bindings/examples/kotlin/AppControlled.kt @@ -21,7 +21,7 @@ private class ScriptedHttpClient : HttpClient { Expected("POST", "https://keycloak.invalid/token", listOf("accept" to "application/json", "content-type" to "application/x-www-form-urlencoded", "authorization" to "Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ="), "grant_type=client_credentials".encodeToByteArray(), 200u, "{\"access_token\":\"offline-token\",\"expires_in\":3600}"), Expected("POST", "https://cyclops.invalid/api/namespaces", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), "{\"name\":\"default\"}".encodeToByteArray(), 201u, "{}"), Expected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxWarmPool\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"replicas\":1,\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxWarmPool\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"replicas\":1,\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}"), - Expected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"claim-1\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), + Expected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"claim-1\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"},\"bindDeadline\":900},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), Expected("GET", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), null, 200u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), Expected("GET", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default", listOf("accept" to "application/json", "content-type" to "application/json", "authorization" to "Bearer offline-token"), null, 200u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxTemplate\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"vmTemplate\":{\"containerDiskImage\":\"registry.example/desktop:offline\",\"services\":[{\"name\":\"mcp\",\"targetPort\":8080}]}}}"), Expected("POST", "https://cyclops.invalid/api/svc/default/offline-sandbox-mcp/mcp", listOf("authorization" to "Bearer offline-token"), "{\"offline\":true}".encodeToByteArray(), 202u, "offline service accepted"), diff --git a/libs/fleet/sdk-bindings/examples/ruby/app_controlled.rb b/libs/fleet/sdk-bindings/examples/ruby/app_controlled.rb index 6e4cad4157..766e8447eb 100644 --- a/libs/fleet/sdk-bindings/examples/ruby/app_controlled.rb +++ b/libs/fleet/sdk-bindings/examples/ruby/app_controlled.rb @@ -20,7 +20,7 @@ def pool_json; { apiVersion:'osgym.cua.ai/v1alpha1',kind:'OSGymSandboxWarmPool', def template_json; { apiVersion:'osgym.cua.ai/v1alpha1',kind:'OSGymSandboxTemplate',metadata:{namespace:'default',name:'default',labels:nil},spec:{vmTemplate:{containerDiskImage:'registry.example/desktop:offline',services:[{name:'mcp',targetPort:8080}]}} }; end def claim_json(bound=false); value={apiVersion:'osgym.cua.ai/v1alpha1',kind:'OSGymSandboxClaim',metadata:{namespace:'default',name:'default',labels:nil},spec:{sandboxTemplateRef:{name:'default'}},status:nil}; value[:status]={phase:'Bound',sandbox:{name:'offline-sandbox'}} if bound; value; end def queue - pool_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools/default"; template_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default"; claim_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default"; pool_body=JSON.generate(pool_json).b; claim_body='{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":null}'.b + pool_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools/default"; template_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default"; claim_url="#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default"; pool_body=JSON.generate(pool_json).b; claim_body='{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"},"bindDeadline":900},"status":null}'.b [Expected.new('POST',TOKEN,[['accept','application/json'],['content-type','application/x-www-form-urlencoded'],['authorization','Basic Y2xpZW50LWlkOmNsaWVudC1zZWNyZXQ=']], 'grant_type=client_credentials'.b,200,{access_token:'offline-token',expires_in:3600}),Expected.new('POST',"#{BASE}/api/namespaces",JSON_HEADERS,'{"name":"default"}'.b,201,{}),Expected.new('POST',pool_url.sub(%r{/default\z}, ''),JSON_HEADERS,pool_body,201,pool_json),Expected.new('POST',claim_url.sub(%r{/default\z}, ''),JSON_HEADERS,claim_body,201,claim_json),Expected.new('GET',claim_url,JSON_HEADERS,nil,200,claim_json(true)),Expected.new('GET',template_url,JSON_HEADERS,nil,200,template_json),Expected.new('POST',"#{BASE}/api/svc/default/offline-sandbox-mcp/mcp",[['authorization','Bearer offline-token']],'{"offline":true}'.b,202,'offline service accepted'),Expected.new('DELETE',claim_url,JSON_HEADERS,nil,204,''),Expected.new('DELETE',pool_url,JSON_HEADERS,nil,204,''),Expected.new('DELETE',"#{BASE}/api/namespaces/default",JSON_HEADERS,nil,204,'')] end diff --git a/libs/fleet/sdk-bindings/kotlin/tests/TestAsyncClient.kt b/libs/fleet/sdk-bindings/kotlin/tests/TestAsyncClient.kt index b10b142094..8d73711b31 100644 --- a/libs/fleet/sdk-bindings/kotlin/tests/TestAsyncClient.kt +++ b/libs/fleet/sdk-bindings/kotlin/tests/TestAsyncClient.kt @@ -62,7 +62,7 @@ private fun lifecycleQueue() = listOf( tokenExpected(), textExpected("POST", "https://cyclops.invalid/api/namespaces", jsonHeaders, "{\"name\":\"default\"}".encodeToByteArray(), 201u, "{}"), textExpected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools", jsonHeaders, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxWarmPool\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"replicas\":1,\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxWarmPool\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"replicas\":1,\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}"), - textExpected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", jsonHeaders, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"claim-1\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), + textExpected("POST", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", jsonHeaders, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"claim-1\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"},\"bindDeadline\":900},\"status\":null}".encodeToByteArray(), 201u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), textExpected("GET", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default", jsonHeaders, null, 200u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxClaim\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"sandboxTemplateRef\":{\"name\":\"default\"}},\"status\":{\"phase\":\"Bound\",\"sandbox\":{\"name\":\"offline-sandbox\"}}}"), textExpected("GET", "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default", jsonHeaders, null, 200u, "{\"apiVersion\":\"osgym.cua.ai/v1alpha1\",\"kind\":\"OSGymSandboxTemplate\",\"metadata\":{\"namespace\":\"default\",\"name\":\"default\",\"labels\":null},\"spec\":{\"vmTemplate\":{\"containerDiskImage\":\"registry.example/desktop:offline\",\"services\":[{\"name\":\"mcp\",\"targetPort\":8080}]}}}"), serviceExpected("{\"offline\":true}".encodeToByteArray(), "offline service accepted".encodeToByteArray()), diff --git a/libs/fleet/sdk-bindings/python/contract_fixture.py b/libs/fleet/sdk-bindings/python/contract_fixture.py index 1d0a6dda0c..e112ed1240 100644 --- a/libs/fleet/sdk-bindings/python/contract_fixture.py +++ b/libs/fleet/sdk-bindings/python/contract_fixture.py @@ -94,7 +94,7 @@ def claim_response(bound=False): def expected_lifecycle(): pool_body = json.dumps(pool_response(), separators=(',', ':')).encode() - claim_body = b'{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":null}' + claim_body = b'{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"},"bindDeadline":900},"status":null}' claim_url = f'{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default' pool_url = f'{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools/default' template_url = f'{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default' diff --git a/libs/fleet/sdk-bindings/ruby/tests/test_async_client.rb b/libs/fleet/sdk-bindings/ruby/tests/test_async_client.rb index 46015c28b2..8c9a8becc8 100644 --- a/libs/fleet/sdk-bindings/ruby/tests/test_async_client.rb +++ b/libs/fleet/sdk-bindings/ruby/tests/test_async_client.rb @@ -44,7 +44,7 @@ def lifecycle_queue template_url = "#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default" claim_url = "#{BASE}/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default" pool_body = JSON.generate(pool_json).b - claim_body = '{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":null}'.b + claim_body = '{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"},"bindDeadline":900},"status":null}'.b [ token_expected, Expected.new('POST', "#{BASE}/api/namespaces", JSON_HEADERS, '{"name":"default"}'.b, 201, {}), diff --git a/libs/fleet/sdk-bindings/swift/tests/TestAsyncClient.swift b/libs/fleet/sdk-bindings/swift/tests/TestAsyncClient.swift index 0c2f17465e..dd536c0f2d 100644 --- a/libs/fleet/sdk-bindings/swift/tests/TestAsyncClient.swift +++ b/libs/fleet/sdk-bindings/swift/tests/TestAsyncClient.swift @@ -46,7 +46,7 @@ private func lifecycleQueue() -> [Expected] { tokenExpected(), Expected(method: "POST", url: "https://cyclops.invalid/api/namespaces", headers: jsonHeaders, body: Data(#"{"name":"default"}"#.utf8), status: 201, response: Data("{}".utf8)), Expected(method: "POST", url: "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxwarmpools", headers: jsonHeaders, body: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxWarmPool","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"replicas":1,"sandboxTemplateRef":{"name":"default"}},"status":null}"#.utf8), status: 201, response: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxWarmPool","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"replicas":1,"sandboxTemplateRef":{"name":"default"}},"status":null}"#.utf8)), - Expected(method: "POST", url: "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", headers: jsonHeaders, body: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":null}"#.utf8), status: 201, response: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":{"phase":"Bound","sandbox":{"name":"offline-sandbox"}}}"#.utf8)), + Expected(method: "POST", url: "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims", headers: jsonHeaders, body: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"claim-1","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"},"bindDeadline":900},"status":null}"#.utf8), status: 201, response: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":{"phase":"Bound","sandbox":{"name":"offline-sandbox"}}}"#.utf8)), Expected(method: "GET", url: "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxclaims/default", headers: jsonHeaders, body: nil, status: 200, response: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxClaim","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"sandboxTemplateRef":{"name":"default"}},"status":{"phase":"Bound","sandbox":{"name":"offline-sandbox"}}}"#.utf8)), Expected(method: "GET", url: "https://cyclops.invalid/api/k8s/apis/osgym.cua.ai/v1alpha1/namespaces/default/osgymsandboxtemplates/default", headers: jsonHeaders, body: nil, status: 200, response: Data(#"{"apiVersion":"osgym.cua.ai/v1alpha1","kind":"OSGymSandboxTemplate","metadata":{"namespace":"default","name":"default","labels":null},"spec":{"vmTemplate":{"containerDiskImage":"registry.example/desktop:offline","services":[{"name":"mcp","targetPort":8080}]}}}"#.utf8)), serviceExpected(body: Data(#"{"offline":true}"#.utf8), response: Data("offline service accepted".utf8)), From 3af8db7111fe409f0d02d26a4a018f0807358912 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Mon, 17 Aug 2026 22:42:55 +0000 Subject: [PATCH 067/117] fix(cyclops-cs): fail closed on flag type mismatch (#6858) CloudCyclopsCs-RevId: 4cb0a91eb8c6179b61eec87ccb5accbf8d4df2c5 --- libs/fleet/backend/auth/flags_test.go | 13 +++++++++++++ libs/fleet/backend/auth/middlewares.go | 22 ++++++++++++---------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/libs/fleet/backend/auth/flags_test.go b/libs/fleet/backend/auth/flags_test.go index 1c7a2aeffb..49e4a4d137 100644 --- a/libs/fleet/backend/auth/flags_test.go +++ b/libs/fleet/backend/auth/flags_test.go @@ -113,6 +113,19 @@ func TestFlagsDataLoadsCardRequirementExemptSubs(t *testing.T) { } } +func TestComputeFlagsDataFailsClosedOnTypeMismatch(t *testing.T) { + t.Setenv("CYCLOPS_CS_ADMIN_SUBS", `["admin-sub"]`) + t.Setenv("CYCLOPS_CS_BILLING_ENABLED", "true") + if err := featureflags.SetupProvider(context.Background(), "development", featureflags.AWSCredentials{}); err != nil { + t.Fatalf("setup dev provider: %v", err) + } + + got := computeFlagsData(context.Background()) + if len(got) != 0 { + t.Fatalf("computeFlagsData() = %v, want empty flags after type mismatch", got) + } +} + func asStrings(v any) []string { items, ok := v.([]interface{}) if !ok { diff --git a/libs/fleet/backend/auth/middlewares.go b/libs/fleet/backend/auth/middlewares.go index cb76693894..6670596aaa 100644 --- a/libs/fleet/backend/auth/middlewares.go +++ b/libs/fleet/backend/auth/middlewares.go @@ -212,7 +212,12 @@ func computeFlagsData(ctx context.Context) map[string]interface{} { if name == "" { continue } - flags[name] = loadStringList(ctx, ffClient, key) + items, err := loadStringList(ctx, ffClient, key) + if err != nil { + slog.Warn("auth: flag load failed; flags will be empty", "flag", key, "err", err) + return map[string]interface{}{} + } + flags[name] = items } callCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() @@ -240,30 +245,27 @@ func opaNameFromFlagKey(key string) string { } // loadStringList resolves a JSON-array flag to []interface{} for OPA's -// input.flags document. The value is StringValue with default "[]"; a -// malformed payload yields an empty list and a warn log so OPA still -// evaluates. +// input.flags document. Evaluation and decoding failures are returned so +// computeFlagsData can discard the entire authorization flag set. // // A 3s per-call deadline keeps an unreachable Parameter Store from // stalling pod startup; the from-env fallback kicks in on timeout. -func loadStringList(ctx context.Context, client *openfeature.Client, flagKey string) []interface{} { +func loadStringList(ctx context.Context, client *openfeature.Client, flagKey string) ([]interface{}, error) { callCtx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() raw, err := client.StringValue(callCtx, flagKey, "[]", openfeature.EvaluationContext{}) if err != nil { - slog.Warn("auth: flag eval failed; using empty list", "flag", flagKey, "err", err) - return []interface{}{} + return nil, fmt.Errorf("evaluate %s as string list: %w", flagKey, err) } var items []string if err := json.Unmarshal([]byte(raw), &items); err != nil { - slog.Warn("auth: flag value is not a JSON string array; using empty list", "flag", flagKey, "err", err) - return []interface{}{} + return nil, fmt.Errorf("decode %s as JSON string array: %w", flagKey, err) } out := make([]interface{}, len(items)) for i, v := range items { out[i] = v } - return out + return out, nil } // LoadOpa prepares the embedded Rego policy and all derived queries. From 76bad7a44cfed7e21f5645b107b5ee9d98fc1f0d Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 19 Aug 2026 01:51:29 +0700 Subject: [PATCH 068/117] fix(agent): reject unsafe UITARS coordinate expressions (#1392) Replace all six evaluations of model-supplied UITARS coordinates with a strict parser that accepts exactly four finite numeric values and fails closed for malformed actions. Tests exercise the full model-response-to-computer-action path for click variants, scroll, and both drag coordinates. Closes #1953 Salvaged from #2221 and #1954. Co-authored-by: RinZ27 <222222878+RinZ27@users.noreply.github.com> Co-authored-by: civonafets Co-authored-by: Jiangrong-W <52068098+Jiangrong-W@users.noreply.github.com> --- .github/release-attribution-config.json | 1 + libs/python/agent/cua_agent/loops/uitars.py | 46 ++++++++-- .../tests/test_uitars_coordinate_security.py | 84 +++++++++++++++++++ 3 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 libs/python/agent/tests/test_uitars_coordinate_security.py diff --git a/.github/release-attribution-config.json b/.github/release-attribution-config.json index 48d41292ff..327f9d4b82 100644 --- a/.github/release-attribution-config.json +++ b/.github/release-attribution-config.json @@ -27,6 +27,7 @@ "klymenko@adobe.com": "pavelklymenko", "m.fuechtenkoetter@posteo.de": "ai-ag2026", "manfred@ubuntu26.04-vm": "ai-ag2026", + "marko.civonafets@gmail.com": "civonafets", "me@madhavajay.com": "madhavajay", "robert@trycua.com": "enchanted-koala", "rwendt1337@gmail.com": "r33drichards", diff --git a/libs/python/agent/cua_agent/loops/uitars.py b/libs/python/agent/cua_agent/loops/uitars.py index cdae4fdf99..dd4f4146f5 100644 --- a/libs/python/agent/cua_agent/loops/uitars.py +++ b/libs/python/agent/cua_agent/loops/uitars.py @@ -195,6 +195,30 @@ def parse_action(action_str): return None +def parse_box_coordinates(box: str) -> Optional[Tuple[float, float, float, float]]: + """Parse exactly four finite numeric UITARS coordinates without evaluating code.""" + try: + value = ast.literal_eval(box) + except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError): + return None + + if not isinstance(value, (list, tuple)) or len(value) != 4: + return None + if not all( + isinstance(number, (int, float)) and not isinstance(number, bool) for number in value + ): + return None + + try: + coordinates = tuple(float(number) for number in value) + except (OverflowError, ValueError): + return None + if not all(math.isfinite(number) for number in coordinates): + return None + + return coordinates[0], coordinates[1], coordinates[2], coordinates[3] + + def parse_uitars_response(text: str, image_width: int, image_height: int) -> List[Dict[str, Any]]: """Parse UITARS model response into structured actions.""" text = text.strip() @@ -303,7 +327,9 @@ def convert_to_computer_actions( elif action_type in ["click", "left_single"]: start_box = action_inputs.get("start_box") if start_box: - coords = eval(start_box) + coords = parse_box_coordinates(start_box) + if coords is None: + continue x = int((coords[0] + coords[2]) / 2 * image_width) y = int((coords[1] + coords[3]) / 2 * image_height) @@ -312,7 +338,9 @@ def convert_to_computer_actions( elif action_type in ["double_click", "left_double"]: start_box = action_inputs.get("start_box") if start_box: - coords = eval(start_box) + coords = parse_box_coordinates(start_box) + if coords is None: + continue x = int((coords[0] + coords[2]) / 2 * image_width) y = int((coords[1] + coords[3]) / 2 * image_height) @@ -321,7 +349,9 @@ def convert_to_computer_actions( elif action_type in ["right_click", "right_single"]: start_box = action_inputs.get("start_box") if start_box: - coords = eval(start_box) + coords = parse_box_coordinates(start_box) + if coords is None: + continue x = int((coords[0] + coords[2]) / 2 * image_width) y = int((coords[1] + coords[3]) / 2 * image_height) @@ -345,7 +375,9 @@ def convert_to_computer_actions( direction = action_inputs.get("direction", "down") if start_box: - coords = eval(start_box) + coords = parse_box_coordinates(start_box) + if coords is None: + continue x = int((coords[0] + coords[2]) / 2 * image_width) y = int((coords[1] + coords[3]) / 2 * image_height) else: @@ -359,8 +391,10 @@ def convert_to_computer_actions( end_box = action_inputs.get("end_box") if start_box and end_box: - start_coords = eval(start_box) - end_coords = eval(end_box) + start_coords = parse_box_coordinates(start_box) + end_coords = parse_box_coordinates(end_box) + if start_coords is None or end_coords is None: + continue start_x = int((start_coords[0] + start_coords[2]) / 2 * image_width) start_y = int((start_coords[1] + start_coords[3]) / 2 * image_height) diff --git a/libs/python/agent/tests/test_uitars_coordinate_security.py b/libs/python/agent/tests/test_uitars_coordinate_security.py new file mode 100644 index 0000000000..559c4c474e --- /dev/null +++ b/libs/python/agent/tests/test_uitars_coordinate_security.py @@ -0,0 +1,84 @@ +"""Security regression tests for model-supplied UITARS coordinates.""" + +import importlib + +import pytest + +uitars = importlib.import_module("cua_agent.loops.uitars") + +_EXECUTED = {"value": False} + + +def _payload(): + _EXECUTED["value"] = True + return (0.1, 0.1, 0.1, 0.1) + + +@pytest.fixture(autouse=True) +def reset_execution_sentinel(): + _EXECUTED["value"] = False + yield + _EXECUTED["value"] = False + + +def convert_model_action(action: str): + """Run one model action through the production parse-and-convert path.""" + parsed = uitars.parse_uitars_response(f"Action: {action}", image_width=1000, image_height=1000) + return uitars.convert_to_computer_actions(parsed, image_width=1000, image_height=1000) + + +@pytest.mark.parametrize( + "action", + [ + 'click(start_box="_payload()")', + 'left_double(start_box="_payload()")', + 'right_single(start_box="_payload()")', + 'scroll(start_box="_payload()", direction="down")', + 'drag(start_box="_payload()", end_box="[0.1, 0.1, 0.1, 0.1]")', + 'drag(start_box="[0.1, 0.1, 0.1, 0.1]", end_box="_payload()")', + ], +) +def test_model_coordinate_expression_is_rejected(action, monkeypatch): + monkeypatch.setattr(uitars, "_payload", _payload, raising=False) + + assert convert_model_action(action) == [] + assert _EXECUTED["value"] is False + + +@pytest.mark.parametrize( + "box", + [ + "1 + 1", + "[1 + 1, 2, 3, 4]", + "__import__('os').getcwd()", + "_payload()", + "[True, 0.2, 0.3, 0.4]", + "[0.1, 0.2]", + "[0.1, 0.2, 0.3, 0.4, 0.5]", + "[float('nan'), 0.2, 0.3, 0.4]", + "[1e1000, 0.2, 0.3, 0.4]", + f"[{'9' * 400}, 0.2, 0.3, 0.4]", + ], +) +def test_coordinate_parser_rejects_non_finite_or_non_numeric_boxes(box): + assert uitars.parse_box_coordinates(box) is None + + +@pytest.mark.parametrize( + ("box", "expected"), + [ + ("[0.1, 0.2, 0.3, 0.4]", (0.1, 0.2, 0.3, 0.4)), + ("(0, 1, 2, 3)", (0.0, 1.0, 2.0, 3.0)), + ], +) +def test_coordinate_parser_accepts_finite_four_number_sequences(box, expected): + assert uitars.parse_box_coordinates(box) == expected + + +def test_valid_model_coordinate_still_produces_click(): + actions = convert_model_action('click(start_box="(500, 500)")') + + assert len(actions) == 1 + assert actions[0]["type"] == "computer_call" + assert actions[0]["action"]["x"] == 500 + assert actions[0]["action"]["y"] == 500 From 9045b0c74f7c7de72fde3d9dc622f2cacf1cf848 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Tue, 18 Aug 2026 20:55:39 +0200 Subject: [PATCH 069/117] fix(cua-driver): preserve history admission on macOS relaunch (#3245) --- .../rust/crates/cua-driver/src/main.rs | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/main.rs b/libs/cua-driver/rust/crates/cua-driver/src/main.rs index 9022e52c8a..3ac545c4e4 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/main.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/main.rs @@ -362,6 +362,27 @@ fn run_mcp_direct(compatibility_mode: bool) -> anyhow::Result<()> { runtime.block_on(proxy::run_direct(driver)) } +fn history_admission_requested(explicit: bool, persisted: bool) -> bool { + explicit || persisted +} + +#[cfg(test)] +mod history_admission_tests { + use super::history_admission_requested; + + #[test] + fn persisted_preview_admission_survives_a_relaunch_without_the_cli_flag() { + assert!(history_admission_requested(false, true)); + } + + #[test] + fn admission_requires_an_explicit_or_persisted_request() { + assert!(!history_admission_requested(false, false)); + assert!(history_admission_requested(true, false)); + assert!(history_admission_requested(true, true)); + } +} + fn mcp_uses_direct_runtime(socket: Option<&str>, direct: bool) -> anyhow::Result { mcp_uses_direct_runtime_for( cua_driver_core::embedded_mode(), @@ -533,7 +554,10 @@ fn main() { std::process::exit(64); } responsibility::reexec_disclaimed_if_needed(); - if let Err(error) = history_runtime::configure_admission(experimental_history) { + if let Err(error) = history_runtime::configure_admission(history_admission_requested( + experimental_history, + history_runtime::preview_admitted_preference(), + )) { eprintln!("cua-driver: Computer History admission error: {error}"); std::process::exit(1); } @@ -929,9 +953,10 @@ fn main() -> anyhow::Result<()> { &grants, )?; responsibility::reexec_disclaimed_if_needed(); - history_runtime::configure_admission( - experimental_history || history_runtime::preview_admitted_preference(), - )?; + history_runtime::configure_admission(history_admission_requested( + experimental_history, + history_runtime::preview_admitted_preference(), + ))?; history_runtime::configure_daemon_launch_state( permission_mode.as_deref(), dangerously_bypass_approvals, From c43f10243856658fe706c08c155a95628fc81248 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Wed, 19 Aug 2026 00:20:51 +0000 Subject: [PATCH 070/117] Update k8s.go (#6753) * Update k8s.go * fix(cyclops): remove stale orch endpoint contracts * docs: design OpenAPI JS SDK removal * docs: plan OpenAPI JS SDK removal * refactor(cyclops): remove OpenAPI JavaScript SDK * docs: fix Cyclops validation command * docs(cyclops): remove stale orch route references * fix(cyclops): stop routing removed orch endpoint * fix(cyclops): resolve nginx rebase conflict CloudCyclopsCs-RevId: bc6d14738b74b9db7640e931a04d7acd76744895 --- libs/fleet/backend/auth/auth.go | 2 +- libs/fleet/backend/auth/authz_orch.rego | 31 - libs/fleet/backend/auth/authz_orch_test.rego | 46 - libs/fleet/backend/auth/authz_ownership.rego | 14 +- .../backend/auth/authz_ownership_test.rego | 9 - libs/fleet/backend/auth/middlewares.go | 4 - .../auth/policy_characterization_test.go | 18 +- libs/fleet/backend/auth/policy_golden_test.go | 2 +- libs/fleet/backend/auth/policy_routes.go | 17 +- .../backend/auth/testdata/orch-route-plan.txt | 8 - .../testdata/route-authorization-table.txt | 220 ---- libs/fleet/backend/docs/docs.go | 73 +- libs/fleet/backend/docs/swagger.json | 73 +- libs/fleet/backend/docs/swagger.yaml | 56 +- libs/fleet/backend/handlers/k8s.go | 66 -- libs/fleet/backend/handlers/ownership_test.go | 24 +- libs/fleet/backend/main.go | 16 +- libs/fleet/backend/main_test.go | 19 + libs/fleet/backend/metrics/metrics.go | 9 +- libs/fleet/backend/metrics/metrics_test.go | 1 - .../backend/route_param_derivation_test.go | 14 + libs/fleet/js-sdk/LICENSE | 21 - libs/fleet/js-sdk/README.md | 93 -- .../js-sdk/examples/claude-agent-sdk.mjs | 441 ------- libs/fleet/js-sdk/examples/pi-agent.mjs | 470 -------- libs/fleet/js-sdk/package-lock.json | 854 -------------- libs/fleet/js-sdk/package.json | 54 - libs/fleet/js-sdk/pnpm-lock.yaml | 557 --------- .../js-sdk/scripts/check-generated-client.mjs | 15 - .../js-sdk/scripts/patch-generated-client.mjs | 32 - .../src/generated/cyclops-cs-backend.ts | 1032 ----------------- libs/fleet/js-sdk/src/index.ts | 62 - libs/fleet/js-sdk/src/token.ts | 73 -- libs/fleet/js-sdk/test/client.test.js | 76 -- .../test/patch-generated-client.test.mjs | 22 - libs/fleet/js-sdk/test/token.test.js | 43 - libs/fleet/js-sdk/tsconfig.json | 14 - libs/fleet/nginx.conf | 10 +- libs/fleet/vite.config.ts | 7 +- 39 files changed, 69 insertions(+), 4529 deletions(-) delete mode 100644 libs/fleet/backend/auth/authz_orch.rego delete mode 100644 libs/fleet/backend/auth/authz_orch_test.rego delete mode 100644 libs/fleet/backend/auth/testdata/orch-route-plan.txt delete mode 100644 libs/fleet/js-sdk/LICENSE delete mode 100644 libs/fleet/js-sdk/README.md delete mode 100644 libs/fleet/js-sdk/examples/claude-agent-sdk.mjs delete mode 100644 libs/fleet/js-sdk/examples/pi-agent.mjs delete mode 100644 libs/fleet/js-sdk/package-lock.json delete mode 100644 libs/fleet/js-sdk/package.json delete mode 100644 libs/fleet/js-sdk/pnpm-lock.yaml delete mode 100644 libs/fleet/js-sdk/scripts/check-generated-client.mjs delete mode 100644 libs/fleet/js-sdk/scripts/patch-generated-client.mjs delete mode 100644 libs/fleet/js-sdk/src/generated/cyclops-cs-backend.ts delete mode 100644 libs/fleet/js-sdk/src/index.ts delete mode 100644 libs/fleet/js-sdk/src/token.ts delete mode 100644 libs/fleet/js-sdk/test/client.test.js delete mode 100644 libs/fleet/js-sdk/test/patch-generated-client.test.mjs delete mode 100644 libs/fleet/js-sdk/test/token.test.js delete mode 100644 libs/fleet/js-sdk/tsconfig.json diff --git a/libs/fleet/backend/auth/auth.go b/libs/fleet/backend/auth/auth.go index 1e0ad0502d..666591779c 100644 --- a/libs/fleet/backend/auth/auth.go +++ b/libs/fleet/backend/auth/auth.go @@ -21,7 +21,7 @@ // // Two conjuncts read something beyond the token. pool_admission.rego reads the // request body; authz_ownership.rego — the namespace-tenancy boundary on -// /api/svc, /api/orch and GET /api/namespaces/{name} — reads a Kubernetes RBAC +// /api/svc and GET /api/namespaces/{name} — reads a Kubernetes RBAC // probe, delivered as input.facts by a FactProvider that handlers registers and // main.go binds. Both are separate leaves rather than rules folded into a // surface, so the expensive read only happens on the requests a cheap sibling diff --git a/libs/fleet/backend/auth/authz_orch.rego b/libs/fleet/backend/auth/authz_orch.rego deleted file mode 100644 index 9928e6ebb2..0000000000 --- a/libs/fleet/backend/auth/authz_orch.rego +++ /dev/null @@ -1,31 +0,0 @@ -# /api/orch/{namespace}/{service}/{path...} — per-namespace orchestrator -# catalog read. -# -# Like /api/svc, this proxy dials Service DNS directly rather than traversing the -# K8s API, so Capsule cannot scope it. This module authenticates the family and -# shape-checks the parameters; namespace ownership is the third conjunct of -# OrchRoutePolicy, authz_ownership.rego, which reads the impersonated RoleBinding -# probe as input.facts.namespace_rbac. -# -# Per-key clients match neither rule below, so they never reach that conjunct: -# their sub is a service account that owns nothing, and their namespace claim is -# not what this surface is scoped by. -package authz_orch - -import data.authz - -default allow = false - -allow { - input.route == "/api/orch/{namespace}/{service}/{path...}" - authz.is_interactive_client - authz.valid_dns_label(input.params.namespace) - authz.valid_dns_label(input.params.service) -} - -allow { - input.route == "/api/orch/{namespace}/{service}/{path...}" - authz.is_user_key_client - authz.valid_dns_label(input.params.namespace) - authz.valid_dns_label(input.params.service) -} diff --git a/libs/fleet/backend/auth/authz_orch_test.rego b/libs/fleet/backend/auth/authz_orch_test.rego deleted file mode 100644 index 7a5b3d6df6..0000000000 --- a/libs/fleet/backend/auth/authz_orch_test.rego +++ /dev/null @@ -1,46 +0,0 @@ -# What /api/orch/{namespace}/{service}/{path...} runs: All(authz_base.allow, authz_orch.allow). -# -# route_allow below is that composition, so every case asserts the verdict the -# route produces rather than one module's half of it. Splitting the policy must -# not turn a test of the whole check into a test of a fragment. -package authz_orch_test - -import rego.v1 - -import data.authz_base -import data.authz_orch - -route_allow if { - authz_base.allow - authz_orch.allow -} - -test_orch_spa_allowed if { - route_allow with input as { - "route": "/api/orch/{namespace}/{service}/{path...}", - "method": "GET", - "path": "/api/orch/mypool/mypool-orchestrator/status", - "params": {"namespace": "mypool", "service": "mypool-orchestrator", "path": "status"}, - "user": {"sub": "user-123", "azp": "cyclops-cs-spa", "namespace": "", "email": "u@example.com"}, - } -} - -test_orch_cua_cli_allowed if { - route_allow with input as { - "route": "/api/orch/{namespace}/{service}/{path...}", - "method": "GET", - "path": "/api/orch/mypool/mypool-orchestrator/status", - "params": {"namespace": "mypool", "service": "mypool-orchestrator", "path": "status"}, - "user": {"sub": "user-123", "azp": "cua-cli", "namespace": "", "email": "u@example.com"}, - } -} - -test_user_api_key_orch_allowed if { - route_allow with input as { - "route": "/api/orch/{namespace}/{service}/{path...}", - "method": "GET", - "path": "/api/orch/mypool/mypool-orchestrator/status", - "params": {"namespace": "mypool", "service": "mypool-orchestrator", "path": "status"}, - "user": {"sub": "user-123", "azp": "ukey-test123abc", "namespace": "", "email": ""}, - } -} diff --git a/libs/fleet/backend/auth/authz_ownership.rego b/libs/fleet/backend/auth/authz_ownership.rego index 0f69846cda..a7c7c7904d 100644 --- a/libs/fleet/backend/auth/authz_ownership.rego +++ b/libs/fleet/backend/auth/authz_ownership.rego @@ -43,9 +43,9 @@ default rbac_allow = false # ── Where the boundary applies ────────────────────────────────────────────── # -# These are exactly the call sites requireNamespaceAccess had. The /api/svc and -# /api/orch proxies dial {service}.{namespace}.svc.cluster.local directly, so -# Capsule cannot scope them; /api/namespaces/{name} GET reads one namespace +# These are exactly the remaining call sites requireNamespaceAccess had. The +# /api/svc proxy dials {service}.{namespace}.svc.cluster.local directly, so +# Capsule cannot scope it; /api/namespaces/{name} GET reads one namespace # through the K8s API and wants the same answer without trusting the read. # # DELETE on /api/namespaces/{name} is deliberately absent: it never ran this @@ -59,17 +59,13 @@ applies { input.route == "/api/svc/{namespace}/{service}/{path...}" } -applies { - input.route == "/api/orch/{namespace}/{service}/{path...}" -} - applies { input.route == "/api/namespaces/{name}" input.method == "GET" } -# target_namespace is the namespace this request is about: /api/svc and -# /api/orch name it {namespace}, /api/namespaces/{name} names it {name}. Keyed +# target_namespace is the namespace this request is about: /api/svc names it +# {namespace}, /api/namespaces/{name} names it {name}. Keyed # off which parameter the route bound rather than off the route itself, so the # two lists cannot disagree — and mirrored in Go by auth.OwnedNamespace, which # is what the fact provider probes and what TestOwnedNamespaceMatchesRego pins diff --git a/libs/fleet/backend/auth/authz_ownership_test.rego b/libs/fleet/backend/auth/authz_ownership_test.rego index 92f72c9677..2dc6524a7a 100644 --- a/libs/fleet/backend/auth/authz_ownership_test.rego +++ b/libs/fleet/backend/auth/authz_ownership_test.rego @@ -114,15 +114,6 @@ test_empty_namespace_is_not_probe_eligible { # ── Where the boundary applies ───────────────────────────────────────────── -test_orch_applies { - probe_eligible with input as { - "route": "/api/orch/{namespace}/{service}/{path...}", - "method": "GET", - "params": {"namespace": "ns-a", "service": "catalog", "path": "items"}, - "user": spa_user, - } -} - test_namespace_get_applies_and_reads_the_name_param { probe_eligible with input as { "route": "/api/namespaces/{name}", diff --git a/libs/fleet/backend/auth/middlewares.go b/libs/fleet/backend/auth/middlewares.go index 6670596aaa..700cd0c6e8 100644 --- a/libs/fleet/backend/auth/middlewares.go +++ b/libs/fleet/backend/auth/middlewares.go @@ -69,7 +69,6 @@ var surfacePolicySources = map[string]struct { "authz-github-trust": {"authz_github_trust.rego", authzGitHubTrustPolicy}, "authz-user-keys": {"authz_user_keys.rego", authzUserKeysPolicy}, "authz-k8s": {"authz_k8s.rego", authzK8sPolicy}, - "authz-orch": {"authz_orch.rego", authzOrchPolicy}, "authz-svc": {"authz_svc.rego", authzSvcPolicy}, "authz-state-query": {"authz_state_query.rego", authzStateQueryPolicy}, } @@ -101,9 +100,6 @@ var authzUserKeysPolicy string //go:embed authz_k8s.rego var authzK8sPolicy string -//go:embed authz_orch.rego -var authzOrchPolicy string - //go:embed authz_svc.rego var authzSvcPolicy string diff --git a/libs/fleet/backend/auth/policy_characterization_test.go b/libs/fleet/backend/auth/policy_characterization_test.go index e866a14bd2..ababa10122 100644 --- a/libs/fleet/backend/auth/policy_characterization_test.go +++ b/libs/fleet/backend/auth/policy_characterization_test.go @@ -43,7 +43,7 @@ import ( // error, the three values the plan can produce. // - Anything downstream of the policy. An "allow" in this table means "the // policy stage let it through", never "the request succeeds". It used to -// mean considerably less than that on /api/svc, /api/orch and GET +// mean considerably less than that on /api/svc and GET // /api/namespaces/{name}, where the namespace-ownership boundary was a Go // check in the handler and invisible here; those routes now carry it as a // policy conjunct, and this table records it. @@ -207,7 +207,7 @@ func characterizationCases() map[string][]routeCase { }, } - // /api/svc and /api/orch gate on the DNS-label shape of their parameters, + // /api/svc gates on the DNS-label shape of its parameters, // on the namespace claim matching the path for per-key and GitHub tokens, // and on the RBAC fact for everyone else. owned-ns / other-ns / unreachable-ns // are the three answers the fact provider gives. @@ -235,20 +235,6 @@ func characterizationCases() map[string][]routeCase { cases["/api/svc/{namespace}/{service}"] = proxyCases(false) cases["/api/svc/{namespace}/{service}/{path...}"] = proxyCases(true) - orchCase := func(name, namespace string) routeCase { - return routeCase{ - name: name, - params: map[string]string{"namespace": namespace, "service": "svc-a", "path": "catalog"}, - path: "/api/orch/" + namespace + "/svc-a/catalog", - } - } - cases["/api/orch/{namespace}/{service}/{path...}"] = []routeCase{ - orchCase("owned-ns", characterizationOwnedNamespace), - orchCase("other-ns", characterizationUnownedNamespace), - orchCase("unreachable-ns", characterizationUnreachableNamespace), - orchCase("invalid-ns", "Not_A_Label"), - } - // /api/k8s carries the richest parameter logic in the policy: the infra-path // list, the admin escape hatch over it, the GitHub namespace grant, and the // pool-admission leaf reading the body. diff --git a/libs/fleet/backend/auth/policy_golden_test.go b/libs/fleet/backend/auth/policy_golden_test.go index fc41946f2a..29520a87e9 100644 --- a/libs/fleet/backend/auth/policy_golden_test.go +++ b/libs/fleet/backend/auth/policy_golden_test.go @@ -47,7 +47,7 @@ import ( // whatsoever. Run it with an empty pipeline and it still passes. // // That survived the arrival of the first fact loader, which is worth stating -// rather than assuming. svc, orch and namespaces now carry a leaf that calls a +// rather than assuming. svc and namespaces carry a leaf that calls a // FactProvider — precisely what sinkFactLoaders exists to move — and it moves // nothing, because NamespaceOwnershipPolicy already declares its cheap children // first and the sort is stable. The pass is load-bearing in the sense that diff --git a/libs/fleet/backend/auth/policy_routes.go b/libs/fleet/backend/auth/policy_routes.go index 3155042c1a..d1dc865daf 100644 --- a/libs/fleet/backend/auth/policy_routes.go +++ b/libs/fleet/backend/auth/policy_routes.go @@ -147,19 +147,8 @@ func UserKeysRoutePolicy() Node { return All(BasePolicy(), surfaceLeaf("authz-user-keys", "data.authz_user_keys.allow")) } -// OrchRoutePolicy guards the per-namespace orchestrator catalog proxy. This -// proxy dials Service DNS directly, so Capsule cannot scope it — the ownership -// conjunct is the whole tenancy boundary. -func OrchRoutePolicy() Node { - return All( - BasePolicy(), - surfaceLeaf("authz-orch", "data.authz_orch.allow"), - NamespaceOwnershipPolicy(), - ) -} - // SvcRoutePolicy guards the generic service proxy, both with and without a -// trailing path. Same direct-dial tenancy boundary as OrchRoutePolicy. +// trailing path. The ownership conjunct is its direct-dial tenancy boundary. func SvcRoutePolicy() Node { return All( BasePolicy(), @@ -258,7 +247,6 @@ var surfacePolicies = map[string]surfacePolicy{ "namespaces": {tree: NamespacesRoutePolicy}, "github-trust": {tree: GitHubTrustRoutePolicy}, "user-keys": {tree: UserKeysRoutePolicy}, - "orch": {tree: OrchRoutePolicy}, "svc": {tree: SvcRoutePolicy}, "state-query": {tree: StateQueryRoutePolicy}, "k8s": { @@ -301,8 +289,7 @@ var routeSurfaces = map[string]string{ "/api/svc/{namespace}/{service}": "svc", "/api/svc/{namespace}/{service}/{path...}": "svc", - "/api/orch/{namespace}/{service}/{path...}": "orch", - "/api/k8s/{path...}": "k8s", + "/api/k8s/{path...}": "k8s", } // AuthenticatedRoutes returns every route the policy layer covers, sorted. diff --git a/libs/fleet/backend/auth/testdata/orch-route-plan.txt b/libs/fleet/backend/auth/testdata/orch-route-plan.txt deleted file mode 100644 index 3edb95044f..0000000000 --- a/libs/fleet/backend/auth/testdata/orch-route-plan.txt +++ /dev/null @@ -1,8 +0,0 @@ -All - Leaf query=data.authz_base.allow source=registered:authz-base - Leaf query=data.authz_orch.allow source=multi["registered:authz","registered:authz-orch"] - Any - Leaf query=data.authz_ownership.claim_allow source=multi["registered:authz","registered:authz-ownership"] - All - Leaf query=data.authz_ownership.probe_eligible source=multi["registered:authz","registered:authz-ownership"] - Leaf query=data.authz_ownership.rbac_allow source=multi["registered:authz","registered:authz-ownership"] facts=namespace_rbac@registered-facts:namespace-rbac diff --git a/libs/fleet/backend/auth/testdata/route-authorization-table.txt b/libs/fleet/backend/auth/testdata/route-authorization-table.txt index 2970a96e2f..4805c10aa4 100644 --- a/libs/fleet/backend/auth/testdata/route-authorization-table.txt +++ b/libs/fleet/backend/auth/testdata/route-authorization-table.txt @@ -2198,226 +2198,6 @@ /api/namespaces/{name} | unreachable-name | QUERY | spa = allow /api/namespaces/{name} | unreachable-name | QUERY | user-key = allow /api/namespaces/{name} | unreachable-name | QUERY | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | user-key = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | DELETE | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | user-key = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | GET | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | user-key = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | PATCH | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | user-key = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | POST | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | spa = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | user-key = deny -/api/orch/{namespace}/{service}/{path...} | invalid-ns | QUERY | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | user-key = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | DELETE | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | user-key = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | GET | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | user-key = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | PATCH | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | user-key = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | POST | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | admin-spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | cua-cli = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | spa = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | user-key = deny -/api/orch/{namespace}/{service}/{path...} | other-ns | QUERY | user-key-verified = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | admin-spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | cua-cli = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | user-key = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | DELETE | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | admin-spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | cua-cli = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | user-key = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | GET | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | admin-spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | cua-cli = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | user-key = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | PATCH | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | admin-spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | cua-cli = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | user-key = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | POST | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | admin-spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | cua-cli = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | spa = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | user-key = allow -/api/orch/{namespace}/{service}/{path...} | owned-ns | QUERY | user-key-verified = allow -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | admin-spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | cua-cli = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | user-key = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | DELETE | user-key-verified = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | admin-spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | cua-cli = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | user-key = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | GET | user-key-verified = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | admin-spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | cua-cli = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | user-key = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | PATCH | user-key-verified = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | admin-spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | cua-cli = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | user-key = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | POST | user-key-verified = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | admin-spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | cua-cli = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | dcr-client = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | empty-sub = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | github-oidc = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | oauth2-proxy = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | per-key-other-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | per-key-owned-ns = deny -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | spa = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | user-key = error -/api/orch/{namespace}/{service}/{path...} | unreachable-ns | QUERY | user-key-verified = error /api/state/query | plain | DELETE | admin-spa = deny /api/state/query | plain | DELETE | cua-cli = deny /api/state/query | plain | DELETE | dcr-client = deny diff --git a/libs/fleet/backend/docs/docs.go b/libs/fleet/backend/docs/docs.go index 233d8aea58..f7f7f6c431 100644 --- a/libs/fleet/backend/docs/docs.go +++ b/libs/fleet/backend/docs/docs.go @@ -890,75 +890,6 @@ const docTemplate = `{ } } }, - "/api/orch/{namespace}/{service}/{path}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Resolves \u003cservice\u003e.\u003cnamespace\u003e.svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels.", - "tags": [ - "passthrough" - ], - "summary": "SPA-authenticated proxy to a per-namespace orchestrator service", - "parameters": [ - { - "type": "string", - "description": "Namespace (DNS-1123 label)", - "name": "namespace", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Service name (DNS-1123 label)", - "name": "service", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Upstream path", - "name": "path", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Upstream response", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - } - } - } - }, "/api/svc/{namespace}/{service}/{path}": { "get": { "security": [ @@ -1543,7 +1474,7 @@ const docTemplate = `{ }, "securityDefinitions": { "BearerAuth": { - "description": "Keycloak access token. For /api/keys and /api/{k8s,orch} the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli).", + "description": "Keycloak access token. For /api/keys and /api/k8s the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli).", "type": "apiKey", "name": "Authorization", "in": "header" @@ -1558,7 +1489,7 @@ var SwaggerInfo = &swag.Spec{ BasePath: "/", Schemes: []string{}, Title: "Cyclops CS Backend API", - Description: "Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / orch / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B).", + Description: "Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B).", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/libs/fleet/backend/docs/swagger.json b/libs/fleet/backend/docs/swagger.json index 2a10998170..897286cfd5 100644 --- a/libs/fleet/backend/docs/swagger.json +++ b/libs/fleet/backend/docs/swagger.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "description": "Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / orch / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B).", + "description": "Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B).", "title": "Cyclops CS Backend API", "contact": {}, "version": "0.1" @@ -883,75 +883,6 @@ } } }, - "/api/orch/{namespace}/{service}/{path}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Resolves \u003cservice\u003e.\u003cnamespace\u003e.svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels.", - "tags": [ - "passthrough" - ], - "summary": "SPA-authenticated proxy to a per-namespace orchestrator service", - "parameters": [ - { - "type": "string", - "description": "Namespace (DNS-1123 label)", - "name": "namespace", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Service name (DNS-1123 label)", - "name": "service", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "Upstream path", - "name": "path", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Upstream response", - "schema": { - "type": "string" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "$ref": "#/definitions/handlers.ErrorResponse" - } - } - } - } - }, "/api/svc/{namespace}/{service}/{path}": { "get": { "security": [ @@ -1536,7 +1467,7 @@ }, "securityDefinitions": { "BearerAuth": { - "description": "Keycloak access token. For /api/keys and /api/{k8s,orch} the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli).", + "description": "Keycloak access token. For /api/keys and /api/k8s the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli).", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/libs/fleet/backend/docs/swagger.yaml b/libs/fleet/backend/docs/swagger.yaml index b9420f6e5d..eb9176a538 100644 --- a/libs/fleet/backend/docs/swagger.yaml +++ b/libs/fleet/backend/docs/swagger.yaml @@ -249,8 +249,8 @@ definitions: info: contact: {} description: Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key - management, service proxies (k8s / orch / svc), and namespace management. All - pool operations use OSGymSandboxClaim CRs (Path B). + management, service proxies (k8s / svc), and namespace management. All pool operations + use OSGymSandboxClaim CRs (Path B). title: Cyclops CS Backend API version: "0.1" paths: @@ -827,54 +827,6 @@ paths: summary: Get a namespace owned by the calling user tags: - namespaces - /api/orch/{namespace}/{service}/{path}: - get: - description: Resolves ..svc.cluster.local at request time - (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an - impersonated RoleBinding probe); OPA additionally validates that namespace - and service look like DNS-1123 labels. - parameters: - - description: Namespace (DNS-1123 label) - in: path - name: namespace - required: true - type: string - - description: Service name (DNS-1123 label) - in: path - name: service - required: true - type: string - - description: Upstream path - in: path - name: path - required: true - type: string - responses: - "200": - description: Upstream response - schema: - type: string - "400": - description: Bad Request - schema: - $ref: '#/definitions/handlers.ErrorResponse' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/handlers.ErrorResponse' - "403": - description: Forbidden - schema: - $ref: '#/definitions/handlers.ErrorResponse' - "502": - description: Bad Gateway - schema: - $ref: '#/definitions/handlers.ErrorResponse' - security: - - BearerAuth: [] - summary: SPA-authenticated proxy to a per-namespace orchestrator service - tags: - - passthrough /api/svc/{namespace}/{service}/{path}: get: description: Proxies to {service}.{namespace}.svc.cluster.local:80. Per-key @@ -1015,8 +967,8 @@ paths: - health securityDefinitions: BearerAuth: - description: Keycloak access token. For /api/keys and /api/{k8s,orch} the token - is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli). + description: Keycloak access token. For /api/keys and /api/k8s the token is an + interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli). in: header name: Authorization type: apiKey diff --git a/libs/fleet/backend/handlers/k8s.go b/libs/fleet/backend/handlers/k8s.go index cacd00123d..ff646087b2 100644 --- a/libs/fleet/backend/handlers/k8s.go +++ b/libs/fleet/backend/handlers/k8s.go @@ -95,69 +95,3 @@ func (h Handlers) K8s(w http.ResponseWriter, r *http.Request) { span.SetAttributes(attribute.Int("http.status_code", rw.statusCode)) metrics.RecordUpstreamProxy("k8s", rw.statusCode, time.Since(start)) } - -// Orch godoc -// -// @Summary SPA-authenticated proxy to a per-namespace orchestrator service -// @Description Resolves ..svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels. -// @Tags passthrough -// @Param namespace path string true "Namespace (DNS-1123 label)" -// @Param service path string true "Service name (DNS-1123 label)" -// @Param path path string true "Upstream path" -// @Success 200 {string} string "Upstream response" -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Failure 403 {object} ErrorResponse -// @Failure 502 {object} ErrorResponse -// @Security BearerAuth -// @Router /api/orch/{namespace}/{service}/{path} [get] -func (h Handlers) Orch(w http.ResponseWriter, r *http.Request) { - user := currentUser(r) - if user == nil { - writeErr(w, http.StatusUnauthorized, "missing user") - return - } - ns := r.PathValue("namespace") - svc := r.PathValue("service") - if !dnsLabel.MatchString(ns) || !dnsLabel.MatchString(svc) { - writeErr(w, http.StatusBadRequest, "invalid namespace or service") - return - } - // Tenancy boundary: this proxy dials Service DNS directly (never the K8s - // API), so Capsule can't scope it. The boundary is the ownership conjunct - // of OrchRoutePolicy, which has already run by the time this handler is - // reached — see auth/authz_ownership.rego. - upstreamPath := "/" + strings.TrimPrefix(r.PathValue("path"), "/") - host := svc + "." + ns + "." + h.GatewayCfg.ClusterDomain - target := &url.URL{Scheme: h.GatewayCfg.Scheme, Host: host + ":" + h.GatewayCfg.Port} - ctx, span := handlerTracer().Start(r.Context(), "orch.proxy", trace.WithAttributes( - attribute.String("proxy.target", "orch"), - attribute.String("http.route", "/api/orch/{namespace}/{service}/{path...}"), - attribute.String("k8s.namespace", ns), - attribute.String("k8s.service", svc), - attribute.String("http.target_path", upstreamPath), - )) - defer span.End() - - rp := httputil.NewSingleHostReverseProxy(target) - origDirector := rp.Director - rp.Director = func(req *http.Request) { - origDirector(req) - req.URL.Path = upstreamPath - req.URL.RawPath = "" - req.URL.RawQuery = r.URL.RawQuery - req.Host = target.Host - req.Header.Del("Authorization") - } - rw := &statusCapture{ResponseWriter: w} - rp.ErrorHandler = func(rw http.ResponseWriter, _ *http.Request, err error) { - span.RecordError(err) - span.SetStatus(codes.Error, "upstream unavailable") - slog.Warn("orch proxy error", "host", host, "path", upstreamPath, "err", err) - http.Error(rw, "upstream unavailable", http.StatusBadGateway) - } - start := time.Now() - rp.ServeHTTP(rw, r.WithContext(ctx)) - span.SetAttributes(attribute.Int("http.status_code", rw.statusCode)) - metrics.RecordUpstreamProxy("orch", rw.statusCode, time.Since(start)) -} diff --git a/libs/fleet/backend/handlers/ownership_test.go b/libs/fleet/backend/handlers/ownership_test.go index 0893d5e0de..550b75d849 100644 --- a/libs/fleet/backend/handlers/ownership_test.go +++ b/libs/fleet/backend/handlers/ownership_test.go @@ -29,7 +29,7 @@ import ( // the same system on a route that serves every noVNC asset. // // The stage is put in front of a stub rather than the real handler on purpose. -// h.Svc and h.Orch dial {service}.{namespace}.svc.cluster.local, which in a test +// h.Svc dials {service}.{namespace}.svc.cluster.local, which in a test // is a DNS timeout; what is under test here is which requests get that far. func testHandlers() Handlers { @@ -62,7 +62,6 @@ func authorizationStage(t *testing.T, route string) (http.Handler, *bool) { const ( svcRoute = "/api/svc/{namespace}/{service}/{path...}" - orchRoute = "/api/orch/{namespace}/{service}/{path...}" namespacesRoute = "/api/namespaces/{name}" namespaceList = "/api/namespaces" ) @@ -468,27 +467,6 @@ func TestSvcRoute_K8sUnavailable_FailsClosedWith502(t *testing.T) { } } -func TestOrchRoute_NonOwnedNamespace_Forbidden(t *testing.T) { - resetOwnershipCache() - fk := newFakeK8s(http.StatusForbidden, `{"kind":"Status"}`) - defer fk.server.Close() - overrideK8sClient(fk.server.Client(), fk.server.URL, "fake-sa-token") - - stage, reached := authorizationStage(t, orchRoute) - r := httptest.NewRequest(http.MethodGet, "/api/orch/other-ns/catalog/items", nil) - r.SetPathValue("namespace", "other-ns") - r.SetPathValue("service", "catalog") - r.SetPathValue("path", "items") - r = withUser(r, spaUser("intruder-uuid")) - w := httptest.NewRecorder() - stage.ServeHTTP(w, r) - - if w.Code != http.StatusForbidden || *reached { - t.Fatalf("status = %d, reached = %v; want 403 and no handler; body = %s", - w.Code, *reached, w.Body.String()) - } -} - func TestNamespacesRoute_GetOtherTenant_Forbidden(t *testing.T) { resetOwnershipCache() fk := newFakeK8s(http.StatusForbidden, `{"kind":"Status"}`) diff --git a/libs/fleet/backend/main.go b/libs/fleet/backend/main.go index e960785e18..b1f4091889 100644 --- a/libs/fleet/backend/main.go +++ b/libs/fleet/backend/main.go @@ -11,12 +11,12 @@ // // @title Cyclops CS Backend API // @version 0.1 -// @description Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / orch / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B). +// @description Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / svc), and namespace management. All pool operations use OSGymSandboxClaim CRs (Path B). // @BasePath / // @securityDefinitions.apikey BearerAuth // @in header // @name Authorization -// @description Keycloak access token. For /api/keys and /api/{k8s,orch} the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli). +// @description Keycloak access token. For /api/keys and /api/k8s the token is an interactive user JWT (azp=cyclops-cs-spa or azp=cua-cli). package main import ( @@ -116,7 +116,7 @@ func onlyLog(route string, h http.HandlerFunc) http.Handler { } func setupRouter(c handlers.Handlers) http.Handler { - // The namespace-ownership conjunct on /api/svc, /api/orch and + // The namespace-ownership conjunct on /api/svc and // /api/namespaces/{name} asks Kubernetes a question, through a probe that // is a handlers method. auth cannot import handlers, so the policy names // the provider and this is where the name is bound to it. It must happen @@ -159,9 +159,8 @@ func setupRouter(c handlers.Handlers) http.Handler { r.Handle("POST /api/billing/webhook", onlyLog("/api/billing/webhook", c.HandleBillingWebhook)) - // Swagger UI + JSON. The SPA points its codegen at /api/swagger/doc.json - // (see cyclops-cs/scripts/codegen.sh). We don't put the doc behind auth - // — the schema is public and the actual endpoints are still gated. + // Swagger UI + JSON. The schema is public API documentation, while the + // actual endpoints remain behind their normal authentication boundaries. r.Handle("GET /api/swagger/", onlyLog("/api/swagger/", func(w http.ResponseWriter, r *http.Request) { httpSwagger.Handler(httpSwagger.URL("/api/swagger/doc.json")).ServeHTTP(w, r) })) @@ -211,12 +210,9 @@ func setupRouter(c handlers.Handlers) http.Handler { r.Handle("/api/svc/{namespace}/{service}/{path...}", withAuthenticatedMiddlewares("/api/svc/{namespace}/{service}/{path...}", c.Svc)) - // K8s API + per-namespace orchestrator catalog access — replace the - // unauthenticated /k8s-api and /orch-api nginx locations from before. + // K8s API access replaces the unauthenticated /k8s-api nginx location. r.Handle("/api/k8s/{path...}", withAuthenticatedMiddlewares("/api/k8s/{path...}", c.K8s)) - r.Handle("/api/orch/{namespace}/{service}/{path...}", - withAuthenticatedMiddlewares("/api/orch/{namespace}/{service}/{path...}", c.Orch)) // Wrap the entire mux in the metrics middleware so every request // (including /healthz and unmatched routes) is recorded. This must be diff --git a/libs/fleet/backend/main_test.go b/libs/fleet/backend/main_test.go index 3a3563b670..2fb4d424c9 100644 --- a/libs/fleet/backend/main_test.go +++ b/libs/fleet/backend/main_test.go @@ -50,6 +50,15 @@ func TestGatewayRoutesAreRemoved(t *testing.T) { } } +func TestOrchRouteIsRemoved(t *testing.T) { + router := setupRouter(handlers.Handlers{}) + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/orch/ns-a/catalog/items", nil)) + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", response.Code, response.Body.String()) + } +} + func TestHealthAndReadinessRoutes(t *testing.T) { router := setupRouter(handlers.Handlers{}) @@ -81,6 +90,16 @@ func TestSwaggerOmitsGatewayRoute(t *testing.T) { } } +func TestSwaggerOmitsOrchRoute(t *testing.T) { + data, err := os.ReadFile("docs/swagger.json") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), `"/api/orch/`) { + t.Fatal("swagger.json still exposes the removed orch route") + } +} + func TestSwaggerUsesBillingSetupSessionRoute(t *testing.T) { data, err := os.ReadFile("docs/swagger.json") if err != nil { diff --git a/libs/fleet/backend/metrics/metrics.go b/libs/fleet/backend/metrics/metrics.go index f081f24d3c..5ef24f9248 100644 --- a/libs/fleet/backend/metrics/metrics.go +++ b/libs/fleet/backend/metrics/metrics.go @@ -58,10 +58,10 @@ var ( Buckets: []float64{0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}, }, []string{"operation", "status"}) - // Upstream proxy SLIs (/api/svc + /api/orch + /api/k8s) + // Upstream proxy SLIs (/api/svc + /api/k8s) UpstreamProxyRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "cyclops_cs_upstream_proxy_requests_total", - Help: "Total requests proxied to upstream services (svc/orch/k8s).", + Help: "Total requests proxied to upstream services (svc/k8s).", }, []string{"target", "status_code"}) BillingWebhookEventsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ @@ -209,7 +209,6 @@ func Middleware(next http.Handler) http.Handler { // // /api/keys/3f2a... → /api/keys/:id // /api/gateway/mypool/reset → /api/gateway/:name/:path -// /api/orch/ns/svc/api/vms → /api/orch/:namespace/:service/:path // /api/k8s/api/v1/pods → /api/k8s/:path func normalizePath(p string) string { switch { @@ -223,8 +222,6 @@ func normalizePath(p string) string { return "/api/gateway/:name/:path" case len(p) > 9 && p[:9] == "/api/svc/": return "/api/svc/:namespace/:service/:path" - case len(p) > 10 && p[:10] == "/api/orch/": - return "/api/orch/:namespace/:service/:path" case len(p) > 9 && p[:9] == "/api/k8s/": return "/api/k8s/:path" default: @@ -245,7 +242,7 @@ func RecordKeycloakRequest(operation string, duration time.Duration, err error) } // RecordUpstreamProxy records a reverse-proxy request to an upstream target. -// target: "gateway", "orch", or "k8s" +// target: "svc" or "k8s" // statusCode: HTTP status code returned from upstream (0 if dial failed) func RecordUpstreamProxy(target string, statusCode int, duration time.Duration) { statusStr := strconv.Itoa(statusCode) diff --git a/libs/fleet/backend/metrics/metrics_test.go b/libs/fleet/backend/metrics/metrics_test.go index 31b3327e78..9ba75951f3 100644 --- a/libs/fleet/backend/metrics/metrics_test.go +++ b/libs/fleet/backend/metrics/metrics_test.go @@ -78,7 +78,6 @@ func TestNormalizePath(t *testing.T) { {"/api/gateway/mypool/vms/list", "/api/gateway/:name/:path"}, {"/api/svc/pool-mypool/my-svc/health", "/api/svc/:namespace/:service/:path"}, {"/api/svc/pool-mypool/my-svc", "/api/svc/:namespace/:service/:path"}, - {"/api/orch/myns/svc/api/vms", "/api/orch/:namespace/:service/:path"}, {"/api/k8s/api/v1/pods", "/api/k8s/:path"}, {"/api/k8s/apis/apps/v1/deployments", "/api/k8s/:path"}, {"/api/swagger/doc.json", "/api/swagger/doc.json"}, diff --git a/libs/fleet/backend/route_param_derivation_test.go b/libs/fleet/backend/route_param_derivation_test.go index a030aa235a..d768725abb 100644 --- a/libs/fleet/backend/route_param_derivation_test.go +++ b/libs/fleet/backend/route_param_derivation_test.go @@ -1,11 +1,13 @@ package main import ( + "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "cyclops-cs-backend/auth" "cyclops-cs-backend/handlers" ) @@ -24,6 +26,14 @@ import ( // for the same route, params and principal — a spa token here is the table's // "spa" row. +type allowedNamespaceFacts struct{} + +func (allowedNamespaceFacts) CacheKey() string { return auth.NamespaceRBACFactProvider } + +func (allowedNamespaceFacts) LoadFacts(context.Context, *http.Request) (auth.FactSet, error) { + return auth.FactSet{"allowed": true}, nil +} + // isPolicyDenial reports whether a response is the authorization stage's own // 403 rather than a handler's. The two are told apart by message: the policy // stage writes the surface's denied message, and every handler past it writes @@ -103,6 +113,10 @@ func TestSvcRouteVerdictFollowsTheNamespaceParam(t *testing.T) { const svcDeniedMessage = "forbidden" router := setupRouter(handlers.Handlers{}) + auth.RegisterFactProvider(auth.NamespaceRBACFactProvider, allowedNamespaceFacts{}) + t.Cleanup(func() { + auth.RegisterFactProvider(auth.NamespaceRBACFactProvider, handlers.NamespaceRBACFacts(handlers.Handlers{})) + }) allowed := httptest.NewRecorder() router.ServeHTTP(allowed, authorizedRequest(t, http.MethodGet, "/api/svc/ns-a/svc-a", nil)) diff --git a/libs/fleet/js-sdk/LICENSE b/libs/fleet/js-sdk/LICENSE deleted file mode 100644 index 5ca2270404..0000000000 --- a/libs/fleet/js-sdk/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Cua - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/libs/fleet/js-sdk/README.md b/libs/fleet/js-sdk/README.md deleted file mode 100644 index 799291b830..0000000000 --- a/libs/fleet/js-sdk/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Cyclops JavaScript client - -`@trycua/cyclops` is the OpenAPI-generated JavaScript and TypeScript client for -Cyclops. It supports ESM applications on Node.js 18 and newer, where the Fetch -API is available as `globalThis.fetch`. - -```sh -npm install @trycua/cyclops -``` - -```ts -import { CyclopsClient } from "@trycua/cyclops" - -const client = new CyclopsClient({ - clientId: process.env.CUA_CLIENT_ID!, - clientSecret: process.env.CUA_CLIENT_SECRET!, -}) - -const response = await client.keys.keysList() -console.log(response.data.keys) -``` - -Keep `clientSecret` in server-side environment variables or a secret manager. -Never expose client credentials in browser bundles or client-side code. - -This README lives under `cyclops-cs/`, which means documentation-only changes -here still exercise the preview image build workflow for pull requests. - -## Node.js and Fetch API support - -The generated client uses the standard Fetch API. Node.js 18+ supplies it -through `globalThis.fetch`, so the SDK has no runtime fetch dependency. A clear -error is thrown before a request when fetch is unavailable. - -Use the `fetch` option to inject a compatible implementation for older runtimes, -polyfills, test doubles, proxies, tracing, or other custom networking behavior. -The same function handles both OAuth token exchange and generated API requests. - -```ts -const client = new CyclopsClient({ - clientId: process.env.CUA_CLIENT_ID!, - clientSecret: process.env.CUA_CLIENT_SECRET!, - fetch: customFetch, -}) -``` - -`securityWorker` is not a browser API. It is a callback generated by -`swagger-typescript-api` that adds authentication parameters before secure API -requests. `CyclopsClient` configures that callback to obtain an OAuth2 token and -add the bearer authorization header. - -## Agent examples - -- [`examples/pi-agent.mjs`](examples/pi-agent.mjs) claims a sandbox and - exposes its computer-server and cua-driver MCP tools to a Pi agent. -- [`examples/claude-agent-sdk.mjs`](examples/claude-agent-sdk.mjs) runs the - same authenticated sandbox and MCP lifecycle with the official Claude Agent - SDK. After building the client and installing the dependencies documented at - the top of the file, run it with `CUA_CLIENT_ID`, `CUA_CLIENT_SECRET`, - `CUA_POOL`, and `ANTHROPIC_API_KEY` set. - -## Generate the client - -Run these commands from `cyclops-cs/js-sdk`: - -```sh -npm ci -npm run generate -npm run generate:check -``` - -`npm run generate` reads `../backend/docs/swagger.json`, writes -`src/generated/cyclops-cs-backend.ts`, and applies the deterministic runtime -fetch guard. `npm run generate:check` regenerates the file and fails if the -checked-in output differs. - -## Generated and handwritten boundary - -- `src/generated/cyclops-cs-backend.ts` contains all endpoint methods and DTOs - represented by the checked-in OpenAPI specification. -- `src/token.ts` contains only the OAuth2 client-credentials token provider. -- `src/index.ts` exports the generated API and configures its generated - `securityWorker` callback with the token provider. It does not implement - endpoint methods or DTOs. - -The Python examples also perform typed `OSGymSandboxClaim` lifecycle operations -and build service-proxy workflows. Those typed claim resources are not modeled -by the current OpenAPI specification. This package therefore does not expose -handwritten claim, wait, release, service URL, or MCP helpers. The generated -spec currently includes generic `/api/k8s/{path}` and -`/api/svc/{namespace}/{service}/{path}` GET operations only; callers needing a -richer claim/service API must wait for those operations and schemas to be added -to the authoritative specification. diff --git a/libs/fleet/js-sdk/examples/claude-agent-sdk.mjs b/libs/fleet/js-sdk/examples/claude-agent-sdk.mjs deleted file mode 100644 index 54f9063b65..0000000000 --- a/libs/fleet/js-sdk/examples/claude-agent-sdk.mjs +++ /dev/null @@ -1,441 +0,0 @@ -/** - * Provision a CUA sandbox and operate it with the official Claude Agent SDK. - * - * This example claims a sandbox from an existing pool configured with the - * computer-server and cua-driver services described by CUA_SERVICES. It - * preflights both Streamable HTTP MCP endpoints, discovers their tools, then - * gives only those remote MCP tools to Claude. - * - * From cyclops-cs/js-sdk: - * npm run build - * npm install --no-save --package-lock=false \ - * @anthropic-ai/claude-agent-sdk @modelcontextprotocol/sdk - * CUA_CLIENT_ID=ukey-... CUA_CLIENT_SECRET=... CUA_POOL=my-pool \ - * ANTHROPIC_API_KEY=... node examples/claude-agent-sdk.mjs - * - * Optional: CUA_BASE_URL, CUA_TOKEN_URL, CUA_CLAIM_NAME, CLAUDE_MODEL, - * CLAUDE_MAX_TURNS, CLAUDE_MAX_BUDGET_USD, CUA_BIND_TIMEOUT_MS, and - * CUA_READY_TIMEOUT_MS. - * - * Use a per-user Cyclops key because Kubernetes claim operations require the - * user's Kubernetes identity. The Claude Agent SDK requires Node.js 18+. - */ - -import { randomUUID } from "node:crypto" - -import { query } from "@anthropic-ai/claude-agent-sdk" -// The MCP SDK calls this class Client; aliasing it makes the session role clear. -import { Client as ClientSession } from "@modelcontextprotocol/sdk/client" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" - -import { ContentType, CyclopsClient, DEFAULT_BASE_URL } from "../dist/index.js" - -const CUA_SERVICES = [ - { - name: "computer-server", - service_suffix: "server", - guest_port: 8000, - mcp_path: "/mcp", - health_path: "/status", - }, - { - name: "cua-driver", - service_suffix: "mcp", - guest_port: 3000, - mcp_path: "/mcp", - health_path: "/healthz", - }, -] - -const CLAIM_GROUP = "osgym.cua.ai" -const CLAIM_VERSION = "v1alpha1" -const CLAIM_PLURAL = "osgymsandboxclaims" -const POLL_INTERVAL_MS = 2_000 -const DEFAULT_BIND_TIMEOUT_MS = 10 * 60_000 -const DEFAULT_READY_TIMEOUT_MS = 5 * 60_000 -const DEFAULT_MAX_TURNS = 20 -const DEFAULT_MAX_BUDGET_USD = 5 -const DEFAULT_TASK = - "Open Firefox and navigate to https://example.com. Take a screenshot and " + - "report the page title. Do not click links, enter data, download files, or " + - "change browser or system settings." - -function section(title) { - console.log(`\n=== ${title} ===`) -} - -function info(message) { - console.log(` ${message}`) -} - -function requiredEnv(name) { - const value = process.env[name] - if (!value) { - throw new Error(`Missing required environment variable: ${name}`) - } - return value -} - -function envNumber(name, fallback) { - const raw = process.env[name] - if (!raw) return fallback - const value = Number(raw) - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`${name} must be a positive number`) - } - return value -} - -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} - -function trimSlashes(path) { - return path.replace(/^\/+|\/+$/g, "") -} - -function claimsPath(pool, claimName) { - const collection = `apis/${CLAIM_GROUP}/${CLAIM_VERSION}/namespaces/${pool}/${CLAIM_PLURAL}` - return claimName ? `${collection}/${claimName}` : collection -} - -function serviceName(sandbox, service) { - return `${sandbox}-${service.service_suffix}` -} - -function serviceUrl(baseUrl, pool, sandbox, service, path) { - const proxyPath = `/api/svc/${pool}/${serviceName(sandbox, service)}/${trimSlashes(path)}` - return new URL(proxyPath, `${baseUrl.replace(/\/$/, "")}/`).toString() -} - -async function describeError(error) { - if (error instanceof Response) { - let body = "" - try { - const parsedError = "error" in error ? error.error : undefined - body = parsedError - ? JSON.stringify(parsedError) - : await error.clone().text() - } catch { - // Status information remains useful when the response body is unavailable. - } - return `${error.status} ${error.statusText}${body ? `: ${body}` : ""}` - } - return error instanceof Error ? error.message : String(error) -} - -async function createClaim(client, pool, claimName) { - // The generated GET helper does not model claim creation, but the generated - // client's public request method supports the same authenticated proxy route. - await client.request({ - path: `/api/k8s/${claimsPath(pool)}`, - method: "POST", - secure: true, - type: ContentType.Json, - format: "json", - body: { - apiVersion: `${CLAIM_GROUP}/${CLAIM_VERSION}`, - kind: "OSGymSandboxClaim", - metadata: { name: claimName }, - spec: { - sandboxTemplateRef: { name: `${pool}-template` }, - bindDeadline: 600, - }, - }, - }) -} - -async function waitForBoundSandbox(client, pool, claimName, timeoutMs) { - const deadline = Date.now() + timeoutMs - let previousPhase - - while (Date.now() < deadline) { - const response = await client.k8S.getK8S(claimsPath(pool, claimName), { - format: "json", - }) - const status = response.data.status ?? {} - const phase = status.phase ?? "Pending" - const sandbox = status.sandbox?.name - - if (phase !== previousPhase) { - info(`claim ${claimName}: ${phase}`) - previousPhase = phase - } - if (phase === "Bound" && sandbox) return sandbox - if (phase === "Failed") { - throw new Error(`Claim ${claimName} failed: ${JSON.stringify(status)}`) - } - await sleep(POLL_INTERVAL_MS) - } - - throw new Error(`Timed out waiting for claim ${claimName} to bind`) -} - -async function waitForService(client, pool, sandbox, service, timeoutMs) { - const deadline = Date.now() + timeoutMs - const name = serviceName(sandbox, service) - const path = trimSlashes(service.health_path) - let lastError = "not ready" - - while (Date.now() < deadline) { - try { - await client.svc.getSvc(pool, name, path, { format: "text" }) - info( - `${service.name} ready at ${name} ` + - `(guest port ${service.guest_port})`, - ) - return - } catch (error) { - lastError = await describeError(error) - await sleep(POLL_INTERVAL_MS) - } - } - - throw new Error(`${service.name} did not become ready: ${lastError}`) -} - -async function releaseClaim(client, pool, claimName) { - await client.request({ - path: `/api/k8s/${claimsPath(pool, claimName)}`, - method: "DELETE", - secure: true, - }) -} - -async function connectMcpServer({ - baseUrl, - pool, - sandbox, - service, - authHeaders, -}) { - const url = serviceUrl(baseUrl, pool, sandbox, service, service.mcp_path) - const transport = new StreamableHTTPClientTransport(new URL(url), { - requestInit: { headers: authHeaders }, - }) - const session = new ClientSession({ - name: `cyclops-claude-${service.name}`, - version: "1.0.0", - }) - - await session.connect(transport) - const { tools } = await session.listTools() - info(`${service.name}: connected to ${url} (${tools.length} tools)`) - - return { service, session, tools, url } -} - -function normalizedServerName(name) { - return name.replace(/[^a-zA-Z0-9_-]/g, "_") -} - -function claudeToolName(serverName, toolName) { - return `mcp__${normalizedServerName(serverName)}__${toolName}` -} - -function buildClaudeMcpConfig(connections, authHeaders) { - return Object.fromEntries( - connections.map(({ service, tools, url }) => [ - service.name, - { - type: "http", - url, - headers: authHeaders, - tools: tools.map((tool) => ({ - name: tool.name, - permission_policy: "always_allow", - })), - timeout: 120_000, - alwaysLoad: true, - }, - ]), - ) -} - -function discoveredClaudeTools(connections) { - return connections.flatMap(({ service, tools }) => - tools.map((tool) => claudeToolName(service.name, tool.name)), - ) -} - -function logAssistantMessage(message) { - for (const block of message.message.content) { - if (block.type === "text") { - for (const line of block.text.trim().split("\n")) info(`Claude: ${line}`) - } else if (block.type === "tool_use") { - info(`Claude calling ${block.name}`) - } - } -} - -async function main() { - const pool = requiredEnv("CUA_POOL") - const baseUrl = process.env.CUA_BASE_URL ?? DEFAULT_BASE_URL - const claimName = - process.env.CUA_CLAIM_NAME ?? `claude-agent-${randomUUID().slice(0, 8)}` - const bindTimeoutMs = envNumber( - "CUA_BIND_TIMEOUT_MS", - DEFAULT_BIND_TIMEOUT_MS, - ) - const readyTimeoutMs = envNumber( - "CUA_READY_TIMEOUT_MS", - DEFAULT_READY_TIMEOUT_MS, - ) - const maxTurns = envNumber("CLAUDE_MAX_TURNS", DEFAULT_MAX_TURNS) - const maxBudgetUsd = envNumber( - "CLAUDE_MAX_BUDGET_USD", - DEFAULT_MAX_BUDGET_USD, - ) - const task = process.argv.slice(2).join(" ") || DEFAULT_TASK - - requiredEnv("ANTHROPIC_API_KEY") - - let client - let claimCreated = false - let claudeQuery - const mcpConnections = [] - - try { - section("1. CUA Authentication") - client = await CyclopsClient.fromKey({ - clientId: requiredEnv("CUA_CLIENT_ID"), - clientSecret: requiredEnv("CUA_CLIENT_SECRET"), - baseUrl, - tokenUrl: process.env.CUA_TOKEN_URL, - }) - info("authenticated with Cyclops client credentials") - - section("2. Provision a CUA Sandbox") - info(`pool: ${pool}`) - for (const service of CUA_SERVICES) { - info( - `${service.name}: ${service.service_suffix} -> guest port ` + - `${service.guest_port}, MCP ${service.mcp_path}`, - ) - } - await createClaim(client, pool, claimName) - claimCreated = true - info(`claim created: ${claimName}`) - const sandbox = await waitForBoundSandbox( - client, - pool, - claimName, - bindTimeoutMs, - ) - info(`sandbox bound: ${sandbox}`) - - for (const service of CUA_SERVICES) { - await waitForService(client, pool, sandbox, service, readyTimeoutMs) - } - - const authHeaders = { - Authorization: `Bearer ${await client.tokenProvider.getToken()}`, - } - - section("3. Connect and Discover MCP Tools") - for (const service of CUA_SERVICES) { - mcpConnections.push( - await connectMcpServer({ - baseUrl, - pool, - sandbox, - service, - authHeaders, - }), - ) - } - - const mcpServers = buildClaudeMcpConfig(mcpConnections, authHeaders) - const allowedTools = discoveredClaudeTools(mcpConnections) - info(`discovered ${allowedTools.length} Claude MCP tools`) - for (const tool of allowedTools) info(tool) - - section("4. Run the Claude Agent Task") - info(`task: ${task}`) - - claudeQuery = query({ - prompt: task, - options: { - allowedTools, - tools: [], - mcpServers, - strictMcpConfig: true, - settingSources: [], - persistSession: false, - maxTurns, - maxBudgetUsd, - model: process.env.CLAUDE_MODEL, - systemPrompt: - "You operate one CUA desktop through two remote MCP servers: " + - "computer-server and cua-driver. Use only the provided MCP tools. " + - "Inspect the desktop before acting, take one action at a time, and " + - "verify each result. Do not enter credentials, submit forms, make " + - "purchases, download files, or change system settings unless the " + - "user explicitly asks.", - stderr: (data) => console.error(`[claude-agent-sdk] ${data.trimEnd()}`), - }, - }) - - let finalResult - for await (const message of claudeQuery) { - if (message.type === "assistant") { - logAssistantMessage(message) - } else if (message.type === "result") { - if (message.subtype === "success") { - finalResult = message.result - info( - `completed in ${message.num_turns} turns ` + - `($${message.total_cost_usd.toFixed(4)})`, - ) - } else { - throw new Error( - `Claude Agent SDK stopped with ${message.subtype}: ` + - `${message.errors.join("; ")}`, - ) - } - } - } - - if (!finalResult) { - throw new Error("Claude Agent SDK completed without a result message") - } - console.log(`\nAgent result:\n${finalResult}`) - } finally { - section("5. Cleanup") - - if (claudeQuery) { - claudeQuery.close() - info("Claude Agent SDK query closed") - } - - for (const connection of mcpConnections.reverse()) { - try { - await connection.session.close() - info(`${connection.service.name} MCP preflight session closed`) - } catch (error) { - info( - `${connection.service.name} MCP cleanup failed: ` + - `${await describeError(error)}`, - ) - } - } - - if (client && claimCreated) { - try { - await releaseClaim(client, pool, claimName) - info(`claim released: ${claimName}`) - } catch (error) { - info(`claim cleanup failed: ${await describeError(error)}`) - } - } - } -} - -try { - await main() -} catch (error) { - console.error( - `\nclaude-agent-sdk example failed: ${await describeError(error)}`, - ) - process.exitCode = 1 -} diff --git a/libs/fleet/js-sdk/examples/pi-agent.mjs b/libs/fleet/js-sdk/examples/pi-agent.mjs deleted file mode 100644 index 25457340b4..0000000000 --- a/libs/fleet/js-sdk/examples/pi-agent.mjs +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Provision a CUA sandbox and let a Pi agent operate it through both MCP servers. - * - * This example claims a sandbox from an existing pool configured with the - * computer-server and cua-driver services described by CUA_SERVICES. - * - * From cyclops-cs/js-sdk: - * npm run build - * npm install --no-save --package-lock=false \ - * @earendil-works/pi-coding-agent @modelcontextprotocol/sdk typebox - * CUA_CLIENT_ID=ukey-... CUA_CLIENT_SECRET=... CUA_POOL=my-pool \ - * node examples/pi-agent.mjs - * - * Pi requires Node.js 22.19 or newer and a configured model/API key. Use a - * per-user Cyclops key because Kubernetes pool and claim operations require the - * user's Kubernetes identity. - */ - -import { randomUUID } from "node:crypto" - -import { - createAgentSession, - defineTool, - SessionManager, -} from "@earendil-works/pi-coding-agent" -// The MCP SDK calls this class Client; aliasing it makes the session role clear. -import { Client as ClientSession } from "@modelcontextprotocol/sdk/client" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { Type } from "typebox" - -import { ContentType, CyclopsClient, DEFAULT_BASE_URL } from "../dist/index.js" - -const CUA_SERVICES = [ - { - name: "computer-server", - service_suffix: "server", - guest_port: 8000, - mcp_path: "/mcp", - health_path: "/status", - }, - { - name: "cua-driver", - service_suffix: "mcp", - guest_port: 3000, - mcp_path: "/mcp", - health_path: "/healthz", - }, -] - -const CLAIM_GROUP = "osgym.cua.ai" -const CLAIM_VERSION = "v1alpha1" -const CLAIM_PLURAL = "osgymsandboxclaims" -const POLL_INTERVAL_MS = 2_000 -const DEFAULT_BIND_TIMEOUT_MS = 10 * 60_000 -const DEFAULT_READY_TIMEOUT_MS = 5 * 60_000 -const DEFAULT_TASK = - "Open Firefox and navigate to https://news.ycombinator.com. " + - "Take a screenshot, then click on the top story." - -function section(title) { - console.log(`\n=== ${title} ===`) -} - -function info(message) { - console.log(` ${message}`) -} - -function requiredEnv(name) { - const value = process.env[name] - if (!value) { - throw new Error(`Missing required environment variable: ${name}`) - } - return value -} - -function envNumber(name, fallback) { - const raw = process.env[name] - if (!raw) return fallback - const value = Number(raw) - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`${name} must be a positive number`) - } - return value -} - -function sleep(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} - -function trimSlashes(path) { - return path.replace(/^\/+|\/+$/g, "") -} - -function claimsPath(pool, claimName) { - const collection = `apis/${CLAIM_GROUP}/${CLAIM_VERSION}/namespaces/${pool}/${CLAIM_PLURAL}` - return claimName ? `${collection}/${claimName}` : collection -} - -function serviceName(sandbox, service) { - return `${sandbox}-${service.service_suffix}` -} - -function serviceUrl(baseUrl, pool, sandbox, service, path) { - const proxyPath = `/api/svc/${pool}/${serviceName(sandbox, service)}/${trimSlashes(path)}` - return new URL(proxyPath, `${baseUrl.replace(/\/$/, "")}/`).toString() -} - -async function describeError(error) { - if (error instanceof Response) { - let body = "" - try { - body = error.error - ? JSON.stringify(error.error) - : await error.clone().text() - } catch { - // Status information remains useful when the response body is unavailable. - } - return `${error.status} ${error.statusText}${body ? `: ${body}` : ""}` - } - return error instanceof Error ? error.message : String(error) -} - -async function createClaim(client, pool, claimName) { - await client.request({ - path: `/api/k8s/${claimsPath(pool)}`, - method: "POST", - secure: true, - type: ContentType.Json, - format: "json", - body: { - apiVersion: `${CLAIM_GROUP}/${CLAIM_VERSION}`, - kind: "OSGymSandboxClaim", - metadata: { name: claimName }, - spec: { - sandboxTemplateRef: { name: `${pool}-template` }, - bindDeadline: 600, - }, - }, - }) -} - -async function waitForBoundSandbox(client, pool, claimName, timeoutMs) { - const deadline = Date.now() + timeoutMs - let previousPhase - - while (Date.now() < deadline) { - const response = await client.k8S.getK8S(claimsPath(pool, claimName), { - format: "json", - }) - const status = response.data.status ?? {} - const phase = status.phase ?? "Pending" - const sandbox = status.sandbox?.name - - if (phase !== previousPhase) { - info(`claim ${claimName}: ${phase}`) - previousPhase = phase - } - if (phase === "Bound" && sandbox) return sandbox - if (phase === "Failed") { - throw new Error(`Claim ${claimName} failed: ${JSON.stringify(status)}`) - } - await sleep(POLL_INTERVAL_MS) - } - - throw new Error(`Timed out waiting for claim ${claimName} to bind`) -} - -async function waitForService(client, pool, sandbox, service, timeoutMs) { - const deadline = Date.now() + timeoutMs - const name = serviceName(sandbox, service) - const path = trimSlashes(service.health_path) - let lastError = "not ready" - - while (Date.now() < deadline) { - try { - await client.svc.getSvc(pool, name, path, { format: "text" }) - info( - `${service.name} ready at ${name} ` + - `(guest port ${service.guest_port})`, - ) - return - } catch (error) { - lastError = await describeError(error) - await sleep(POLL_INTERVAL_MS) - } - } - - throw new Error(`${service.name} did not become ready: ${lastError}`) -} - -async function releaseClaim(client, pool, claimName) { - await client.request({ - path: `/api/k8s/${claimsPath(pool, claimName)}`, - method: "DELETE", - secure: true, - }) -} - -async function connectMcpServer({ - baseUrl, - pool, - sandbox, - service, - authHeaders, -}) { - const url = serviceUrl(baseUrl, pool, sandbox, service, service.mcp_path) - const transport = new StreamableHTTPClientTransport(new URL(url), { - requestInit: { headers: authHeaders }, - }) - const session = new ClientSession({ - name: `cyclops-pi-${service.name}`, - version: "1.0.0", - }) - - await session.connect(transport) - const { tools } = await session.listTools() - info(`${service.name}: connected to ${url} (${tools.length} tools)`) - - return { service, session, tools } -} - -function safeToolName(name) { - return name.replace(/[^a-zA-Z0-9_-]/g, "_") -} - -function toPiContent(content = []) { - return content.map((item) => { - if (item.type === "text") { - return { type: "text", text: item.text } - } - if (item.type === "image") { - return { type: "image", data: item.data, mimeType: item.mimeType } - } - return { type: "text", text: JSON.stringify(item) } - }) -} - -function registerPiTools(connections) { - const names = new Set() - - return connections.flatMap(({ service, session, tools }) => - tools.map((tool) => { - const name = `${safeToolName(service.name)}__${safeToolName(tool.name)}` - if (names.has(name)) throw new Error(`Duplicate Pi tool name: ${name}`) - names.add(name) - - return defineTool({ - name, - label: `${service.name}: ${tool.name}`, - description: - tool.description ?? - `${tool.name} from the ${service.name} MCP server`, - parameters: Type.Unsafe( - tool.inputSchema ?? { type: "object", properties: {} }, - ), - execute: async (_toolCallId, parameters, signal) => { - try { - const result = await session.callTool( - { name: tool.name, arguments: parameters }, - undefined, - signal ? { signal } : undefined, - ) - return { - content: toPiContent(result.content), - details: { - server: service.name, - tool: tool.name, - structuredContent: result.structuredContent, - }, - isError: result.isError === true, - } - } catch (error) { - return { - content: [ - { - type: "text", - text: - `${service.name}/${tool.name} failed: ` + - `${await describeError(error)}`, - }, - ], - details: { server: service.name, tool: tool.name }, - isError: true, - } - } - }, - }) - }), - ) -} - -async function takeInitialScreenshot(connections) { - const computerServer = connections.find( - ({ service }) => service.name === "computer-server", - ) - const screenshotTool = computerServer?.tools.find( - (tool) => tool.name === "screenshot", - ) - if (!computerServer || !screenshotTool) { - throw new Error("computer-server did not advertise a screenshot tool") - } - - const result = await computerServer.session.callTool({ - name: screenshotTool.name, - arguments: {}, - }) - if (result.isError) { - throw new Error( - `Initial screenshot failed: ${JSON.stringify(result.content)}`, - ) - } - - const images = toPiContent(result.content).filter( - (item) => item.type === "image", - ) - info(`initial screenshot captured (${images.length} image attachment)`) - return images -} - -function finalAssistantText(session) { - const message = session.state.messages.findLast( - (candidate) => candidate.role === "assistant", - ) - return ( - message?.content - .filter((part) => part.type === "text") - .map((part) => part.text) - .join("") ?? "" - ) -} - -async function main() { - const pool = requiredEnv("CUA_POOL") - const baseUrl = process.env.CUA_BASE_URL ?? DEFAULT_BASE_URL - const claimName = - process.env.CUA_CLAIM_NAME ?? `pi-agent-${randomUUID().slice(0, 8)}` - const bindTimeoutMs = envNumber( - "CUA_BIND_TIMEOUT_MS", - DEFAULT_BIND_TIMEOUT_MS, - ) - const readyTimeoutMs = envNumber( - "CUA_READY_TIMEOUT_MS", - DEFAULT_READY_TIMEOUT_MS, - ) - const task = process.argv.slice(2).join(" ") || DEFAULT_TASK - - let client - let claimCreated = false - let piSession - const mcpConnections = [] - - try { - section("1. CUA Authentication") - client = await CyclopsClient.fromKey({ - clientId: requiredEnv("CUA_CLIENT_ID"), - clientSecret: requiredEnv("CUA_CLIENT_SECRET"), - baseUrl, - tokenUrl: process.env.CUA_TOKEN_URL, - }) - info("authenticated with Cyclops client credentials") - - section("2. Provision a CUA Sandbox") - info(`pool: ${pool}`) - for (const service of CUA_SERVICES) { - info( - `${service.name}: ${service.service_suffix} -> guest port ` + - `${service.guest_port}, MCP ${service.mcp_path}`, - ) - } - await createClaim(client, pool, claimName) - claimCreated = true - info(`claim created: ${claimName}`) - const sandbox = await waitForBoundSandbox( - client, - pool, - claimName, - bindTimeoutMs, - ) - info(`sandbox bound: ${sandbox}`) - - for (const service of CUA_SERVICES) { - await waitForService(client, pool, sandbox, service, readyTimeoutMs) - } - - const authHeaders = { - Authorization: `Bearer ${await client.tokenProvider.getToken()}`, - } - - section("3. Connect to MCP Servers") - for (const service of CUA_SERVICES) { - mcpConnections.push( - await connectMcpServer({ - baseUrl, - pool, - sandbox, - service, - authHeaders, - }), - ) - } - - section("4. Register Tools with Pi") - const customTools = registerPiTools(mcpConnections) - info(`registered ${customTools.length} namespaced MCP tools`) - for (const tool of customTools) info(tool.name) - - section("5. Run the Agent Task") - const initialImages = await takeInitialScreenshot(mcpConnections) - info(`task: ${task}`) - - const createdSession = await createAgentSession({ - tools: customTools.map((tool) => tool.name), - customTools, - sessionManager: SessionManager.inMemory(), - }) - piSession = createdSession.session - piSession.subscribe((event) => { - if (event.type === "tool_execution_start") { - info(`Pi calling ${event.toolName}`) - } - }) - - await piSession.prompt( - "You operate one CUA desktop through two MCP servers. Tool names are " + - "namespaced by server (for example computer-server__screenshot and " + - "cua-driver__click). Inspect the screenshot, take one action at a " + - `time, and verify the result after each action.\n\nTask: ${task}`, - { images: initialImages }, - ) - - const result = finalAssistantText(piSession) - if (!result) throw new Error("Pi completed without a text result") - console.log(`\nAgent result:\n${result}`) - } finally { - section("6. Cleanup") - - if (piSession) { - piSession.dispose() - info("Pi session disposed") - } - - for (const connection of mcpConnections.reverse()) { - try { - await connection.session.close() - info(`${connection.service.name} MCP session closed`) - } catch (error) { - info( - `${connection.service.name} MCP cleanup failed: ` + - `${await describeError(error)}`, - ) - } - } - - if (client && claimCreated) { - try { - await releaseClaim(client, pool, claimName) - info(`claim released: ${claimName}`) - } catch (error) { - info(`claim cleanup failed: ${await describeError(error)}`) - } - } - } -} - -try { - await main() -} catch (error) { - console.error(`\npi-agent failed: ${await describeError(error)}`) - process.exitCode = 1 -} diff --git a/libs/fleet/js-sdk/package-lock.json b/libs/fleet/js-sdk/package-lock.json deleted file mode 100644 index 0dfd9f58fc..0000000000 --- a/libs/fleet/js-sdk/package-lock.json +++ /dev/null @@ -1,854 +0,0 @@ -{ - "name": "@trycua/cyclops", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@trycua/cyclops", - "version": "0.1.0", - "license": "MIT", - "devDependencies": { - "swagger-typescript-api": "13.2.7", - "typescript": "5.7.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@biomejs/js-api": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@biomejs/js-api/-/js-api-1.0.0.tgz", - "integrity": "sha512-69OfQ7+09AtiCIg+k+aU3rEsGit5o/SJWCS3BeBH/2nJYdJGi0cIx+ybka8i1EK69aNcZxYO1y1iAAEmYMq1HA==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "@biomejs/wasm-bundler": "^2.0.0", - "@biomejs/wasm-nodejs": "^2.0.0", - "@biomejs/wasm-web": "^2.0.0" - }, - "peerDependenciesMeta": { - "@biomejs/wasm-bundler": { - "optional": true - }, - "@biomejs/wasm-nodejs": { - "optional": true - }, - "@biomejs/wasm-web": { - "optional": true - } - } - }, - "node_modules/@biomejs/wasm-nodejs": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@biomejs/wasm-nodejs/-/wasm-nodejs-2.0.5.tgz", - "integrity": "sha512-pihpBMylewgDdGFZHRkgmc3OajuGIJPXhvfYuKCNK/CWyJMrYEFmPKs8Iq1kY0sYMmGlTbD4K2udV03KYa+r0Q==", - "dev": true, - "license": "MIT OR Apache-2.0" - }, - "node_modules/@exodus/schemasafe": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", - "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/swagger-schema-official": { - "version": "2.0.25", - "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.25.tgz", - "integrity": "sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/c12": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", - "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "confbox": "^0.2.4", - "defu": "^6.1.6", - "dotenv": "^17.3.1", - "exsolve": "^1.0.8", - "giget": "^3.2.0", - "jiti": "^2.6.1", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^2.1.0", - "pkg-types": "^2.3.0", - "rc9": "^3.0.1" - }, - "peerDependencies": { - "magicast": "*" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/confbox": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", - "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eta": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", - "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - }, - "funding": { - "url": "https://github.com/eta-dev/eta?sponsor=1" - } - }, - "node_modules/exsolve": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", - "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/giget": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", - "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", - "dev": true, - "license": "MIT", - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/http2-client": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", - "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "5.1.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", - "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch-h2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", - "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "http2-client": "^1.2.5" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/node-readfiles": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", - "integrity": "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es6-promise": "^3.2.1" - } - }, - "node_modules/oas-kit-common": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", - "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "fast-safe-stringify": "^2.0.7" - } - }, - "node_modules/oas-linter": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", - "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@exodus/schemasafe": "^1.0.0-rc.2", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-resolver": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", - "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "node-fetch-h2": "^2.3.0", - "oas-kit-common": "^1.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "resolve": "resolve.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-schema-walker": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", - "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", - "dev": true, - "license": "BSD-3-Clause", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/oas-validator": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", - "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "call-me-maybe": "^1.0.1", - "oas-kit-common": "^1.0.8", - "oas-linter": "^3.2.2", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "reftools": "^1.1.9", - "should": "^13.2.1", - "yaml": "^1.10.0" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/perfect-debounce": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", - "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/pkg-types": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", - "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.4", - "exsolve": "^1.0.8", - "pathe": "^2.0.3" - } - }, - "node_modules/rc9": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", - "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "defu": "^6.1.6", - "destr": "^2.0.5" - } - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reftools": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", - "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", - "dev": true, - "license": "BSD-3-Clause", - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/should": { - "version": "13.2.3", - "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", - "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-equal": "^2.0.0", - "should-format": "^3.0.3", - "should-type": "^1.4.0", - "should-type-adaptors": "^1.0.1", - "should-util": "^1.0.0" - } - }, - "node_modules/should-equal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", - "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-type": "^1.4.0" - } - }, - "node_modules/should-format": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", - "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-type": "^1.3.0", - "should-type-adaptors": "^1.0.1" - } - }, - "node_modules/should-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", - "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/should-type-adaptors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", - "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "should-type": "^1.3.0", - "should-util": "^1.0.0" - } - }, - "node_modules/should-util": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", - "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/swagger-schema-official": { - "version": "2.0.0-bab6bed", - "resolved": "https://registry.npmjs.org/swagger-schema-official/-/swagger-schema-official-2.0.0-bab6bed.tgz", - "integrity": "sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==", - "dev": true, - "license": "ISC" - }, - "node_modules/swagger-typescript-api": { - "version": "13.2.7", - "resolved": "https://registry.npmjs.org/swagger-typescript-api/-/swagger-typescript-api-13.2.7.tgz", - "integrity": "sha512-rfqqoRFpZJPl477M/snMJPM90EvI8WqhuUHSF5ecC2r/w376T29+QXNJFVPsJmbFu5rBc/8m3vhArtMctjONdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@biomejs/js-api": "1.0.0", - "@biomejs/wasm-nodejs": "2.0.5", - "@types/swagger-schema-official": "^2.0.25", - "c12": "^3.0.4", - "citty": "^0.1.6", - "consola": "^3.4.2", - "eta": "^2.2.0", - "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "nanoid": "^5.1.5", - "swagger-schema-official": "2.0.0-bab6bed", - "swagger2openapi": "^7.0.8", - "typescript": "~5.8.3" - }, - "bin": { - "sta": "dist/cli.js", - "swagger-typescript-api": "dist/cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/swagger-typescript-api/node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/swagger2openapi": { - "version": "7.0.8", - "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", - "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "call-me-maybe": "^1.0.1", - "node-fetch": "^2.6.1", - "node-fetch-h2": "^2.3.0", - "node-readfiles": "^0.2.0", - "oas-kit-common": "^1.0.8", - "oas-resolver": "^2.5.6", - "oas-schema-walker": "^1.1.5", - "oas-validator": "^5.0.8", - "reftools": "^1.1.9", - "yaml": "^1.10.0", - "yargs": "^17.0.1" - }, - "bin": { - "boast": "boast.js", - "oas-validate": "oas-validate.js", - "swagger2openapi": "swagger2openapi.js" - }, - "funding": { - "url": "https://github.com/Mermade/oas-kit?sponsor=1" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - } - } -} diff --git a/libs/fleet/js-sdk/package.json b/libs/fleet/js-sdk/package.json deleted file mode 100644 index 83728f1f17..0000000000 --- a/libs/fleet/js-sdk/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@trycua/cyclops", - "version": "0.1.0", - "description": "OpenAPI-generated JavaScript and TypeScript client for Cua Cyclops", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "engines": { - "node": ">=18.0.0" - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "clean": "rm -rf dist", - "generate": "swagger-typescript-api generate -p ../backend/docs/swagger.json -o ./src/generated -n cyclops-cs-backend.ts --module-name-index 1 --extract-request-body --extract-request-params --extract-response-body --extract-response-error && node ./scripts/patch-generated-client.mjs", - "generate:check": "node ./scripts/check-generated-client.mjs", - "build": "npm run clean && tsc -p tsconfig.json", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "npm run build && node --test test/*.test.js test/*.test.mjs", - "prepublishOnly": "npm run generate:check && npm run typecheck && npm test && npm run build" - }, - "keywords": [ - "cua", - "cyclops", - "openapi", - "sdk" - ], - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/trycua/cloud.git", - "directory": "cyclops-cs/js-sdk" - }, - "bugs": { - "url": "https://github.com/trycua/cloud/issues" - }, - "homepage": "https://github.com/trycua/cloud/tree/main/cyclops-cs/js-sdk#readme", - "devDependencies": { - "swagger-typescript-api": "13.2.7", - "typescript": "5.7.2" - } -} diff --git a/libs/fleet/js-sdk/pnpm-lock.yaml b/libs/fleet/js-sdk/pnpm-lock.yaml deleted file mode 100644 index c1c5754a65..0000000000 --- a/libs/fleet/js-sdk/pnpm-lock.yaml +++ /dev/null @@ -1,557 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - swagger-typescript-api: - specifier: 13.2.7 - version: 13.2.7 - typescript: - specifier: 5.7.2 - version: 5.7.2 - -packages: - - '@biomejs/js-api@1.0.0': - resolution: {integrity: sha512-69OfQ7+09AtiCIg+k+aU3rEsGit5o/SJWCS3BeBH/2nJYdJGi0cIx+ybka8i1EK69aNcZxYO1y1iAAEmYMq1HA==} - peerDependencies: - '@biomejs/wasm-bundler': ^2.0.0 - '@biomejs/wasm-nodejs': ^2.0.0 - '@biomejs/wasm-web': ^2.0.0 - peerDependenciesMeta: - '@biomejs/wasm-bundler': - optional: true - '@biomejs/wasm-nodejs': - optional: true - '@biomejs/wasm-web': - optional: true - - '@biomejs/wasm-nodejs@2.0.5': - resolution: {integrity: sha512-pihpBMylewgDdGFZHRkgmc3OajuGIJPXhvfYuKCNK/CWyJMrYEFmPKs8Iq1kY0sYMmGlTbD4K2udV03KYa+r0Q==} - - '@exodus/schemasafe@1.3.0': - resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} - - '@types/swagger-schema-official@2.0.25': - resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - c12@3.3.4: - resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} - peerDependencies: - magicast: '*' - peerDependenciesMeta: - magicast: - optional: true - - call-me-maybe@1.0.2: - resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} - - chokidar@5.0.0: - resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} - engines: {node: '>= 20.19.0'} - - citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} - - defu@6.1.7: - resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - es6-promise@3.3.1: - resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - eta@2.2.0: - resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} - engines: {node: '>=6.0.0'} - - exsolve@1.1.0: - resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} - - fast-safe-stringify@2.1.1: - resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - giget@3.3.0: - resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} - hasBin: true - - http2-client@1.3.5: - resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - jiti@2.7.0: - resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} - hasBin: true - - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} - hasBin: true - - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - - nanoid@5.1.16: - resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} - engines: {node: ^18 || >=20} - hasBin: true - - node-fetch-h2@2.3.0: - resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} - engines: {node: 4.x || >=6.0.0} - - node-fetch@2.7.0: - resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-readfiles@0.2.0: - resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} - - oas-kit-common@1.0.8: - resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} - - oas-linter@3.2.2: - resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} - - oas-resolver@2.5.6: - resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} - hasBin: true - - oas-schema-walker@1.1.5: - resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} - - oas-validator@5.0.8: - resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} - - ohash@2.0.11: - resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - perfect-debounce@2.1.0: - resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} - - pkg-types@2.3.1: - resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - - rc9@3.0.1: - resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} - - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} - engines: {node: '>= 20.19.0'} - - reftools@1.1.9: - resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - should-equal@2.0.0: - resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} - - should-format@3.0.3: - resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} - - should-type-adaptors@1.1.0: - resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} - - should-type@1.4.0: - resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} - - should-util@1.0.1: - resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} - - should@13.2.3: - resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - swagger-schema-official@2.0.0-bab6bed: - resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} - - swagger-typescript-api@13.2.7: - resolution: {integrity: sha512-rfqqoRFpZJPl477M/snMJPM90EvI8WqhuUHSF5ecC2r/w376T29+QXNJFVPsJmbFu5rBc/8m3vhArtMctjONdw==} - engines: {node: '>=20'} - hasBin: true - - swagger2openapi@7.0.8: - resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} - hasBin: true - - tr46@0.0.3: - resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} - - typescript@5.7.2: - resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} - engines: {node: '>=14.17'} - hasBin: true - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - - webidl-conversions@3.0.1: - resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - - whatwg-url@5.0.0: - resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yaml@1.10.3: - resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} - engines: {node: '>= 6'} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} - -snapshots: - - '@biomejs/js-api@1.0.0(@biomejs/wasm-nodejs@2.0.5)': - optionalDependencies: - '@biomejs/wasm-nodejs': 2.0.5 - - '@biomejs/wasm-nodejs@2.0.5': {} - - '@exodus/schemasafe@1.3.0': {} - - '@types/swagger-schema-official@2.0.25': {} - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - c12@3.3.4: - dependencies: - chokidar: 5.0.0 - confbox: 0.2.4 - defu: 6.1.7 - dotenv: 17.4.2 - exsolve: 1.1.0 - giget: 3.3.0 - jiti: 2.7.0 - ohash: 2.0.11 - pathe: 2.0.3 - perfect-debounce: 2.1.0 - pkg-types: 2.3.1 - rc9: 3.0.1 - - call-me-maybe@1.0.2: {} - - chokidar@5.0.0: - dependencies: - readdirp: 5.0.0 - - citty@0.1.6: - dependencies: - consola: 3.4.2 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - confbox@0.2.4: {} - - consola@3.4.2: {} - - defu@6.1.7: {} - - destr@2.0.5: {} - - dotenv@17.4.2: {} - - emoji-regex@8.0.0: {} - - es6-promise@3.3.1: {} - - escalade@3.2.0: {} - - eta@2.2.0: {} - - exsolve@1.1.0: {} - - fast-safe-stringify@2.1.1: {} - - get-caller-file@2.0.5: {} - - giget@3.3.0: {} - - http2-client@1.3.5: {} - - is-fullwidth-code-point@3.0.0: {} - - jiti@2.7.0: {} - - js-yaml@4.3.0: - dependencies: - argparse: 2.0.1 - - lodash@4.18.1: {} - - nanoid@5.1.16: {} - - node-fetch-h2@2.3.0: - dependencies: - http2-client: 1.3.5 - - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - - node-readfiles@0.2.0: - dependencies: - es6-promise: 3.3.1 - - oas-kit-common@1.0.8: - dependencies: - fast-safe-stringify: 2.1.1 - - oas-linter@3.2.2: - dependencies: - '@exodus/schemasafe': 1.3.0 - should: 13.2.3 - yaml: 1.10.3 - - oas-resolver@2.5.6: - dependencies: - node-fetch-h2: 2.3.0 - oas-kit-common: 1.0.8 - reftools: 1.1.9 - yaml: 1.10.3 - yargs: 17.7.3 - - oas-schema-walker@1.1.5: {} - - oas-validator@5.0.8: - dependencies: - call-me-maybe: 1.0.2 - oas-kit-common: 1.0.8 - oas-linter: 3.2.2 - oas-resolver: 2.5.6 - oas-schema-walker: 1.1.5 - reftools: 1.1.9 - should: 13.2.3 - yaml: 1.10.3 - - ohash@2.0.11: {} - - pathe@2.0.3: {} - - perfect-debounce@2.1.0: {} - - pkg-types@2.3.1: - dependencies: - confbox: 0.2.4 - exsolve: 1.1.0 - pathe: 2.0.3 - - rc9@3.0.1: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - - readdirp@5.0.0: {} - - reftools@1.1.9: {} - - require-directory@2.1.1: {} - - should-equal@2.0.0: - dependencies: - should-type: 1.4.0 - - should-format@3.0.3: - dependencies: - should-type: 1.4.0 - should-type-adaptors: 1.1.0 - - should-type-adaptors@1.1.0: - dependencies: - should-type: 1.4.0 - should-util: 1.0.1 - - should-type@1.4.0: {} - - should-util@1.0.1: {} - - should@13.2.3: - dependencies: - should-equal: 2.0.0 - should-format: 3.0.3 - should-type: 1.4.0 - should-type-adaptors: 1.1.0 - should-util: 1.0.1 - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - swagger-schema-official@2.0.0-bab6bed: {} - - swagger-typescript-api@13.2.7: - dependencies: - '@biomejs/js-api': 1.0.0(@biomejs/wasm-nodejs@2.0.5) - '@biomejs/wasm-nodejs': 2.0.5 - '@types/swagger-schema-official': 2.0.25 - c12: 3.3.4 - citty: 0.1.6 - consola: 3.4.2 - eta: 2.2.0 - js-yaml: 4.3.0 - lodash: 4.18.1 - nanoid: 5.1.16 - swagger-schema-official: 2.0.0-bab6bed - swagger2openapi: 7.0.8 - typescript: 5.8.3 - transitivePeerDependencies: - - '@biomejs/wasm-bundler' - - '@biomejs/wasm-web' - - encoding - - magicast - - swagger2openapi@7.0.8: - dependencies: - call-me-maybe: 1.0.2 - node-fetch: 2.7.0 - node-fetch-h2: 2.3.0 - node-readfiles: 0.2.0 - oas-kit-common: 1.0.8 - oas-resolver: 2.5.6 - oas-schema-walker: 1.1.5 - oas-validator: 5.0.8 - reftools: 1.1.9 - yaml: 1.10.3 - yargs: 17.7.3 - transitivePeerDependencies: - - encoding - - tr46@0.0.3: {} - - typescript@5.7.2: {} - - typescript@5.8.3: {} - - webidl-conversions@3.0.1: {} - - whatwg-url@5.0.0: - dependencies: - tr46: 0.0.3 - webidl-conversions: 3.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - y18n@5.0.8: {} - - yaml@1.10.3: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 diff --git a/libs/fleet/js-sdk/scripts/check-generated-client.mjs b/libs/fleet/js-sdk/scripts/check-generated-client.mjs deleted file mode 100644 index 7f77a6bbe4..0000000000 --- a/libs/fleet/js-sdk/scripts/check-generated-client.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { execFileSync } from "node:child_process" -import { readFile, writeFile } from "node:fs/promises" - -const generatedClientPath = new URL("../src/generated/cyclops-cs-backend.ts", import.meta.url) -const before = await readFile(generatedClientPath) - -try { - execFileSync("npm", ["run", "generate"], { stdio: "inherit" }) - const after = await readFile(generatedClientPath) - if (!before.equals(after)) { - throw new Error("Generated client drift detected; run npm run generate and commit the result") - } -} finally { - await writeFile(generatedClientPath, before) -} diff --git a/libs/fleet/js-sdk/scripts/patch-generated-client.mjs b/libs/fleet/js-sdk/scripts/patch-generated-client.mjs deleted file mode 100644 index c038c16f3b..0000000000 --- a/libs/fleet/js-sdk/scripts/patch-generated-client.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises" - -const generatedClientPath = process.argv[2] ?? new URL("../src/generated/cyclops-cs-backend.ts", import.meta.url) -const original = await readFile(generatedClientPath, "utf8") -const oldImplementation = ` private customFetch = (...fetchParams: Parameters) => - fetch(...fetchParams);` -const newImplementation = ` private customFetch: typeof fetch = (...fetchParams) => { - const runtimeFetch = globalThis.fetch; - if (typeof runtimeFetch !== "function") { - throw new Error( - "Fetch API is unavailable. @trycua/cyclops requires Node.js 18+ or an injected customFetch implementation.", - ); - } - return runtimeFetch(...fetchParams); - };` - -if (!original.includes(oldImplementation)) { - throw new Error("Generated HttpClient fetch implementation did not match the expected template") -} - -const oldBillingSummaryCard = " card?: BillingCardSummary | null;"; -const newBillingSummaryCard = " card: BillingCardSummary | null;"; - -if (!original.includes(oldBillingSummaryCard)) { - throw new Error("Generated BillingSummary card declaration did not match the expected template"); -} - -const patched = original - .replace(oldImplementation, newImplementation) - .replace(oldBillingSummaryCard, newBillingSummaryCard); - -await writeFile(generatedClientPath, patched) diff --git a/libs/fleet/js-sdk/src/generated/cyclops-cs-backend.ts b/libs/fleet/js-sdk/src/generated/cyclops-cs-backend.ts deleted file mode 100644 index db3d838f2d..0000000000 --- a/libs/fleet/js-sdk/src/generated/cyclops-cs-backend.ts +++ /dev/null @@ -1,1032 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ -// @ts-nocheck -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - -export interface BillingCardSummary { - brand?: string; - exp_month?: number; - exp_year?: number; - last4?: string; -} - -export interface BillingSummary { - card: BillingCardSummary | null; - payment_method_present: boolean; -} - -export interface HandlersBillingSessionResponse { - url?: string; -} - -export interface HandlersConfigResponse { - /** - * Admin is true when the caller is in input.flags.admin_subs (OPA-evaluated). - * Non-admins get the customer view: infra-only nav (Nodes, Operator events) - * is hidden in the SPA and the corresponding kubectl-proxy paths are denied - * server-side by authz.rego. - */ - admin?: boolean; - billing?: boolean; -} - -export interface HandlersCreateKeyRequest { - /** @example "ci-prod" */ - name?: string; - /** @example "test-pool" */ - namespace?: string; -} - -export interface HandlersCreateKeyResponse { - client_id?: string; - client_secret?: string; - name?: string; - namespace?: string; - token_url?: string; -} - -export interface HandlersCreateNamespaceRequest { - /** @example "my-workspace" */ - name?: string; -} - -export interface HandlersCreateUserKeyRequest { - /** @example "my-ci-key" */ - name?: string; - /** @example ["[\"ns1\"","\"ns2\"]"] */ - scope?: string[]; -} - -export interface HandlersCreateUserKeyResponse { - client_id?: string; - client_secret?: string; - name?: string; - scope?: string[]; - token_url?: string; -} - -export interface HandlersErrorResponse { - error?: string; -} - -export interface HandlersHealthResponse { - ok?: boolean; -} - -export interface HandlersListKeysResponse { - keys?: KeycloakKeyClient[]; -} - -export interface HandlersListUserKeysResponse { - keys?: HandlersUserKeyResponse[]; -} - -export interface HandlersNamespaceResponse { - createdAt?: string; - labels?: Record; - name?: string; - status?: string; -} - -export interface HandlersUserKeyResponse { - client_id?: string; - id?: string; - name?: string; - scope?: string[]; -} - -export interface KeycloakKeyClient { - client_id?: string; - id?: string; - name?: string; - namespace?: string; - owner_sub?: string; -} - -export type BatchLanesCreateError = Record; - -export type BatchLanesDeleteError = Record; - -export type BatchSubmitCreateError = Record; - -export type BatchDeleteError = Record; - -export type BatchResultsListError = Record; - -export type BatchStatusListError = Record; - -export type BillingPortalSessionCreateData = HandlersBillingSessionResponse; - -export type BillingPortalSessionCreateError = Record; - -export type BillingSetupSessionCreateData = HandlersBillingSessionResponse; - -export type BillingSetupSessionCreateError = Record; - -export type BillingSummaryListData = BillingSummary; - -export type BillingSummaryListError = Record; - -export type BillingWebhookCreateData = any; - -export type BillingWebhookCreateError = Record; - -export type ConfigListData = HandlersConfigResponse; - -export type ConfigListError = HandlersErrorResponse; - -export type GatewayDetailError = HandlersErrorResponse; - -export type GetK8SData = string; - -export type GetK8SError = HandlersErrorResponse; - -export type KeysListData = HandlersListKeysResponse; - -export type KeysListError = HandlersErrorResponse; - -export type KeysCreateData = HandlersCreateKeyResponse; - -export type KeysCreateError = HandlersErrorResponse; - -export type KeysDeleteData = any; - -export type KeysDeleteError = HandlersErrorResponse; - -export type LabelDeleteError = Record; - -export type LabelBatchCreateError = Record; - -export type LabelResultsListError = Record; - -export type LabelStatusListError = Record; - -export type NamespacesListData = HandlersNamespaceResponse[]; - -export type NamespacesListError = HandlersErrorResponse; - -export type NamespacesCreateData = HandlersNamespaceResponse; - -export type NamespacesCreateError = HandlersErrorResponse; - -export type NamespacesDeleteData = any; - -export type NamespacesDeleteError = HandlersErrorResponse; - -export type OrchDetailData = string; - -export type OrchDetailError = HandlersErrorResponse; - -export type GetSvcData = string; - -export type GetSvcError = HandlersErrorResponse; - -export type UserKeysListData = HandlersListUserKeysResponse; - -export type UserKeysListError = HandlersErrorResponse; - -export type UserKeysCreateData = HandlersCreateUserKeyResponse; - -export type UserKeysCreateError = HandlersErrorResponse; - -export type UserKeysDeleteData = any; - -export type UserKeysDeleteError = HandlersErrorResponse; - -export type HealthzListData = HandlersHealthResponse; - -export type QueryParamsType = Record; -export type ResponseFormat = keyof Omit; - -export interface FullRequestParams extends Omit { - /** set parameter to `true` for call `securityWorker` for this request */ - secure?: boolean; - /** request path */ - path: string; - /** content type of request body */ - type?: ContentType; - /** query params */ - query?: QueryParamsType; - /** format of response (i.e. response.json() -> format: "json") */ - format?: ResponseFormat; - /** request body */ - body?: unknown; - /** base url */ - baseUrl?: string; - /** request cancellation token */ - cancelToken?: CancelToken; -} - -export type RequestParams = Omit< - FullRequestParams, - "body" | "method" | "query" | "path" ->; - -export interface ApiConfig { - baseUrl?: string; - baseApiParams?: Omit; - securityWorker?: ( - securityData: SecurityDataType | null, - ) => Promise | RequestParams | void; - customFetch?: typeof fetch; -} - -export interface HttpResponse - extends Response { - data: D; - error: E; -} - -type CancelToken = Symbol | string | number; - -export enum ContentType { - Json = "application/json", - JsonApi = "application/vnd.api+json", - FormData = "multipart/form-data", - UrlEncoded = "application/x-www-form-urlencoded", - Text = "text/plain", -} - -export class HttpClient { - public baseUrl: string = "/"; - private securityData: SecurityDataType | null = null; - private securityWorker?: ApiConfig["securityWorker"]; - private abortControllers = new Map(); - private customFetch: typeof fetch = (...fetchParams) => { - const runtimeFetch = globalThis.fetch; - if (typeof runtimeFetch !== "function") { - throw new Error( - "Fetch API is unavailable. @trycua/cyclops requires Node.js 18+ or an injected customFetch implementation.", - ); - } - return runtimeFetch(...fetchParams); - }; - - private baseApiParams: RequestParams = { - credentials: "same-origin", - headers: {}, - redirect: "follow", - referrerPolicy: "no-referrer", - }; - - constructor(apiConfig: ApiConfig = {}) { - Object.assign(this, apiConfig); - } - - public setSecurityData = (data: SecurityDataType | null) => { - this.securityData = data; - }; - - protected encodeQueryParam(key: string, value: any) { - const encodedKey = encodeURIComponent(key); - return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; - } - - protected addQueryParam(query: QueryParamsType, key: string) { - return this.encodeQueryParam(key, query[key]); - } - - protected addArrayQueryParam(query: QueryParamsType, key: string) { - const value = query[key]; - return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); - } - - protected toQueryString(rawQuery?: QueryParamsType): string { - const query = rawQuery || {}; - const keys = Object.keys(query).filter( - (key) => "undefined" !== typeof query[key], - ); - return keys - .map((key) => - Array.isArray(query[key]) - ? this.addArrayQueryParam(query, key) - : this.addQueryParam(query, key), - ) - .join("&"); - } - - protected addQueryParams(rawQuery?: QueryParamsType): string { - const queryString = this.toQueryString(rawQuery); - return queryString ? `?${queryString}` : ""; - } - - private contentFormatters: Record any> = { - [ContentType.Json]: (input: any) => - input !== null && (typeof input === "object" || typeof input === "string") - ? JSON.stringify(input) - : input, - [ContentType.JsonApi]: (input: any) => - input !== null && (typeof input === "object" || typeof input === "string") - ? JSON.stringify(input) - : input, - [ContentType.Text]: (input: any) => - input !== null && typeof input !== "string" - ? JSON.stringify(input) - : input, - [ContentType.FormData]: (input: any) => - Object.keys(input || {}).reduce((formData, key) => { - const property = input[key]; - formData.append( - key, - property instanceof Blob - ? property - : typeof property === "object" && property !== null - ? JSON.stringify(property) - : `${property}`, - ); - return formData; - }, new FormData()), - [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), - }; - - protected mergeRequestParams( - params1: RequestParams, - params2?: RequestParams, - ): RequestParams { - return { - ...this.baseApiParams, - ...params1, - ...(params2 || {}), - headers: { - ...(this.baseApiParams.headers || {}), - ...(params1.headers || {}), - ...((params2 && params2.headers) || {}), - }, - }; - } - - protected createAbortSignal = ( - cancelToken: CancelToken, - ): AbortSignal | undefined => { - if (this.abortControllers.has(cancelToken)) { - const abortController = this.abortControllers.get(cancelToken); - if (abortController) { - return abortController.signal; - } - return void 0; - } - - const abortController = new AbortController(); - this.abortControllers.set(cancelToken, abortController); - return abortController.signal; - }; - - public abortRequest = (cancelToken: CancelToken) => { - const abortController = this.abortControllers.get(cancelToken); - - if (abortController) { - abortController.abort(); - this.abortControllers.delete(cancelToken); - } - }; - - public request = async ({ - body, - secure, - path, - type, - query, - format, - baseUrl, - cancelToken, - ...params - }: FullRequestParams): Promise> => { - const secureParams = - ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && - this.securityWorker && - (await this.securityWorker(this.securityData))) || - {}; - const requestParams = this.mergeRequestParams(params, secureParams); - const queryString = query && this.toQueryString(query); - const payloadFormatter = this.contentFormatters[type || ContentType.Json]; - const responseFormat = format || requestParams.format; - - return this.customFetch( - `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, - { - ...requestParams, - headers: { - ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData - ? { "Content-Type": type } - : {}), - }, - signal: - (cancelToken - ? this.createAbortSignal(cancelToken) - : requestParams.signal) || null, - body: - typeof body === "undefined" || body === null - ? null - : payloadFormatter(body), - }, - ).then(async (response) => { - const r = response.clone() as HttpResponse; - r.data = null as unknown as T; - r.error = null as unknown as E; - - const data = !responseFormat - ? r - : await response[responseFormat]() - .then((data) => { - if (r.ok) { - r.data = data; - } else { - r.error = data; - } - return r; - }) - .catch((e) => { - r.error = e; - return r; - }); - - if (cancelToken) { - this.abortControllers.delete(cancelToken); - } - - if (!response.ok) throw data; - return data; - }); - }; -} - -/** - * @title Cyclops CS Backend API - * @version 0.1 - * @baseUrl / - * @contact - * - * Backend sidecar for the cyclops-cs SPA — Keycloak-authenticated key management, service proxies (k8s / orch / svc), namespace management, and deprecated gateway / batch / label routes that now return 410 Gone. All pool operations use OSGymSandboxClaim CRs (Path B). - */ -export class Api< - SecurityDataType extends unknown, -> extends HttpClient { - /** - * No description - * - * @tags health - * @name HealthzList - * @summary Liveness/readiness probe - * @request GET:/healthz - */ - healthzList = (params: RequestParams = {}) => - this.request({ - path: `/healthz`, - method: "GET", - format: "json", - ...params, - }); - - batch = { - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchLanesCreate - * @summary Deprecated batch lanes route - * @request POST:/api/batch/{pool}/lanes - * @deprecated - * @secure - */ - batchLanesCreate: (pool: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/lanes`, - method: "POST", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchLanesDelete - * @summary Deprecated batch lanes route - * @request DELETE:/api/batch/{pool}/lanes - * @deprecated - * @secure - */ - batchLanesDelete: (pool: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/lanes`, - method: "DELETE", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchSubmitCreate - * @summary Deprecated batch submission route - * @request POST:/api/batch/{pool}/submit - * @deprecated - * @secure - */ - batchSubmitCreate: (pool: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/submit`, - method: "POST", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchDelete - * @summary Deprecated batch delete route - * @request DELETE:/api/batch/{pool}/{id} - * @deprecated - * @secure - */ - batchDelete: (pool: string, id: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/${id}`, - method: "DELETE", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchResultsList - * @summary Deprecated batch results route - * @request GET:/api/batch/{pool}/{id}/results - * @deprecated - * @secure - */ - batchResultsList: (pool: string, id: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/${id}/results`, - method: "GET", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags batch - * @name BatchStatusList - * @summary Deprecated batch status route - * @request GET:/api/batch/{pool}/{id}/status - * @deprecated - * @secure - */ - batchStatusList: (pool: string, id: string, params: RequestParams = {}) => - this.request({ - path: `/api/batch/${pool}/${id}/status`, - method: "GET", - secure: true, - ...params, - }), - }; - billing = { - /** - * @description Creates a Stripe-hosted Billing Portal Session for the customer owned by the authenticated Cyclops subject. - * - * @tags billing - * @name BillingPortalSessionCreate - * @summary Create Stripe Billing Portal Session - * @request POST:/api/billing/portal-session - * @secure - */ - billingPortalSessionCreate: (params: RequestParams = {}) => - this.request< - BillingPortalSessionCreateData, - BillingPortalSessionCreateError - >({ - path: `/api/billing/portal-session`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Creates a Stripe-hosted Checkout Session in setup mode for reusable off-session card collection. - * - * @tags billing - * @name BillingSetupSessionCreate - * @summary Create Stripe card setup Session - * @request POST:/api/billing/setup-session - * @secure - */ - billingSetupSessionCreate: (params: RequestParams = {}) => - this.request< - BillingSetupSessionCreateData, - BillingSetupSessionCreateError - >({ - path: `/api/billing/setup-session`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Returns a sanitized Stripe-backed billing summary for the authenticated Cyclops subject. - * - * @tags billing - * @name BillingSummaryList - * @summary Billing summary - * @request GET:/api/billing/summary - * @secure - */ - billingSummaryList: (params: RequestParams = {}) => - this.request({ - path: `/api/billing/summary`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Verifies a Stripe-signed raw webhook body and configures the default payment method for completed fleet setup intents. - * - * @tags billing - * @name BillingWebhookCreate - * @summary Receive Stripe webhook - * @request POST:/api/billing/webhook - */ - billingWebhookCreate: (params: RequestParams = {}) => - this.request({ - path: `/api/billing/webhook`, - method: "POST", - type: ContentType.Json, - ...params, - }), - }; - config = { - /** - * @description Returns OPA-evaluated feature flags for the authenticated SPA user. `admin` is true when the caller's JWT sub appears in input.flags.admin_subs. - * - * @tags config - * @name ConfigList - * @summary Per-user feature flags - * @request GET:/api/config - * @secure - */ - configList: (params: RequestParams = {}) => - this.request({ - path: `/api/config`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - }; - gateway = { - /** - * @description The per-pool HTTP orchestrator has been deprecated and removed. All pools now use OSGymSandboxClaim CRs exclusively (Path B). This endpoint returns 410 Gone for any request. See CUA-609. - * - * @tags gateway - * @name GatewayDetail - * @summary DEPRECATED: Per-pool orchestrator reverse-proxy (CUA-609) - * @request GET:/api/gateway/{name}/{path} - */ - gatewayDetail: (name: string, path?: string, params: RequestParams = {}) => - this.request({ - path: `/api/gateway/${name}/${path}`, - method: "GET", - ...params, - }), - }; - k8S = { - /** - * @description Forwards requests to http://127.0.0.1:8001 (the kubectl-proxy sidecar) so the caller can read K8s resources via the pod ServiceAccount. SPA-only; OPA-gated. The policy is an allowlist: only enumerated group/version/resource/method combinations are proxied (the osgym.cua.ai and cua.ai fleet CRDs, namespaced pod/service reads, pod logs and metrics, KubeVirt reads, and API discovery). Anything else, including Kubernetes events and any cluster-scoped path, is denied and never reaches the sidecar. - * - * @tags passthrough - * @name GetK8S - * @summary Authenticated proxy to the in-pod kubectl-proxy sidecar - * @request GET:/api/k8s/{path} - * @secure - */ - getK8S: (path: string, params: RequestParams = {}) => - this.request({ - path: `/api/k8s/${path}`, - method: "GET", - secure: true, - ...params, - }), - }; - keys = { - /** - * No description - * - * @tags keys - * @name KeysList - * @summary List the calling user's API keys - * @request GET:/api/keys - * @secure - */ - keysList: (params: RequestParams = {}) => - this.request({ - path: `/api/keys`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Creates a Keycloak service-account client owned by the calling user. The returned `client_secret` is shown exactly once. - * - * @tags keys - * @name KeysCreate - * @summary Create a new API key - * @request POST:/api/keys - * @secure - */ - keysCreate: (body: HandlersCreateKeyRequest, params: RequestParams = {}) => - this.request({ - path: `/api/keys`, - method: "POST", - body: body, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * No description - * - * @tags keys - * @name KeysDelete - * @summary Revoke an API key by Keycloak client UUID - * @request DELETE:/api/keys/{id} - * @secure - */ - keysDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/api/keys/${id}`, - method: "DELETE", - secure: true, - ...params, - }), - }; - label = { - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags label - * @name LabelDelete - * @summary Deprecated label delete route - * @request DELETE:/api/label/{pool}/{label} - * @deprecated - * @secure - */ - labelDelete: (pool: string, label: string, params: RequestParams = {}) => - this.request({ - path: `/api/label/${pool}/${label}`, - method: "DELETE", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags label - * @name LabelBatchCreate - * @summary Deprecated label batch route - * @request POST:/api/label/{pool}/{label}/batch - * @deprecated - * @secure - */ - labelBatchCreate: ( - pool: string, - label: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/label/${pool}/${label}/batch`, - method: "POST", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags label - * @name LabelResultsList - * @summary Deprecated label results route - * @request GET:/api/label/{pool}/{label}/results - * @deprecated - * @secure - */ - labelResultsList: ( - pool: string, - label: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/label/${pool}/${label}/results`, - method: "GET", - secure: true, - ...params, - }), - - /** - * @description Route is deprecated and unavailable. Returns 410 Gone for every request. The orchestrator-backed batch surface is retired; callers must migrate to the replacement flow. - * - * @tags label - * @name LabelStatusList - * @summary Deprecated label status route - * @request GET:/api/label/{pool}/{label}/status - * @deprecated - * @secure - */ - labelStatusList: ( - pool: string, - label: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/label/${pool}/${label}/status`, - method: "GET", - secure: true, - ...params, - }), - }; - namespaces = { - /** - * @description Returns namespaces owned by the caller's Capsule Tenant. The list is scoped by a capsule.clastix.io/tenant= label selector built from the authenticated subject, so it stays fail-closed even when Capsule Proxy isn't filtering. - * - * @tags namespaces - * @name NamespacesList - * @summary List the calling user's namespaces - * @request GET:/api/namespaces - * @secure - */ - namespacesList: (params: RequestParams = {}) => - this.request({ - path: `/api/namespaces`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Creates a K8s namespace via impersonation. Capsule's webhook intercepts the creation and assigns it to the user's Tenant. - * - * @tags namespaces - * @name NamespacesCreate - * @summary Create a namespace for the calling user - * @request POST:/api/namespaces - * @secure - */ - namespacesCreate: ( - body: HandlersCreateNamespaceRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/namespaces`, - method: "POST", - body: body, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Deletes a K8s namespace via impersonation. Capsule blocks deletion if the namespace doesn't belong to the user's Tenant. - * - * @tags namespaces - * @name NamespacesDelete - * @summary Delete a namespace owned by the calling user - * @request DELETE:/api/namespaces/{name} - * @secure - */ - namespacesDelete: (name: string, params: RequestParams = {}) => - this.request({ - path: `/api/namespaces/${name}`, - method: "DELETE", - secure: true, - ...params, - }), - }; - orch = { - /** - * @description Resolves ..svc.cluster.local at request time (in-cluster DNS). The caller must hold RBAC in {namespace} (verified via an impersonated RoleBinding probe); OPA additionally validates that namespace and service look like DNS-1123 labels. - * - * @tags passthrough - * @name OrchDetail - * @summary SPA-authenticated proxy to a per-namespace orchestrator service - * @request GET:/api/orch/{namespace}/{service}/{path} - * @secure - */ - orchDetail: ( - namespace: string, - service: string, - path: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/orch/${namespace}/${service}/${path}`, - method: "GET", - secure: true, - ...params, - }), - }; - svc = { - /** - * @description Proxies to {service}.{namespace}.svc.cluster.local:80. Per-key tokens are bound to their `namespace` claim; all other principals (SPA, user keys, oauth2-proxy browser sessions) must hold RBAC in {namespace}, verified via an impersonated RoleBinding probe. Strips Authorization before forwarding. - * - * @tags gateway - * @name GetSvc - * @summary Authenticated reverse proxy to a K8s Service in a namespace the caller owns - * @request GET:/api/svc/{namespace}/{service}/{path} - * @secure - */ - getSvc: ( - namespace: string, - service: string, - path?: string, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/svc/${namespace}/${service}/${path}`, - method: "GET", - secure: true, - ...params, - }), - }; - userKeys = { - /** - * No description - * - * @tags user-keys - * @name UserKeysList - * @summary List the calling user's API keys - * @request GET:/api/user-keys - * @secure - */ - userKeysList: (params: RequestParams = {}) => - this.request({ - path: `/api/user-keys`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Creates a Keycloak service-account client that acts on behalf of the calling user. The client_secret is returned exactly once; it cannot be retrieved later. - * - * @tags user-keys - * @name UserKeysCreate - * @summary Create a per-user API key - * @request POST:/api/user-keys - * @secure - */ - userKeysCreate: ( - body: HandlersCreateUserKeyRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/api/user-keys`, - method: "POST", - body: body, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * No description - * - * @tags user-keys - * @name UserKeysDelete - * @summary Revoke a per-user API key - * @request DELETE:/api/user-keys/{id} - * @secure - */ - userKeysDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/api/user-keys/${id}`, - method: "DELETE", - secure: true, - ...params, - }), - }; -} diff --git a/libs/fleet/js-sdk/src/index.ts b/libs/fleet/js-sdk/src/index.ts deleted file mode 100644 index a284615e3c..0000000000 --- a/libs/fleet/js-sdk/src/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Api, type ApiConfig } from "./generated/cyclops-cs-backend.js" -import { - ClientCredentialsTokenProvider, - type ClientCredentialsTokenProviderOptions, -} from "./token.js" - -export * from "./generated/cyclops-cs-backend.js" -export * from "./token.js" - -export const DEFAULT_TOKEN_URL = - "https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token" -export const DEFAULT_BASE_URL = "https://run.cua.ai" - -export interface CyclopsClientOptions { - clientId: string - clientSecret: string - tokenUrl?: string - baseUrl?: string - fetch?: typeof fetch - refreshBufferMs?: number -} - -function resolveFetch(customFetch?: typeof fetch): typeof fetch { - const runtimeFetch = customFetch ?? globalThis.fetch - if (typeof runtimeFetch !== "function") { - throw new Error( - "Fetch API is unavailable. @trycua/cyclops requires Node.js 18+ or an injected fetch implementation.", - ) - } - return runtimeFetch -} - -export class CyclopsClient extends Api { - readonly tokenProvider: ClientCredentialsTokenProvider - - constructor(options: CyclopsClientOptions) { - const fetch = resolveFetch(options.fetch) - const tokenOptions: ClientCredentialsTokenProviderOptions = { - tokenUrl: options.tokenUrl ?? DEFAULT_TOKEN_URL, - clientId: options.clientId, - clientSecret: options.clientSecret, - fetch, - refreshBufferMs: options.refreshBufferMs, - } - const tokenProvider = new ClientCredentialsTokenProvider(tokenOptions) - const config: ApiConfig = { - baseUrl: options.baseUrl ?? DEFAULT_BASE_URL, - customFetch: fetch, - securityWorker: async () => ({ - headers: { Authorization: `Bearer ${await tokenProvider.getToken()}` }, - }), - } - super(config) - this.tokenProvider = tokenProvider - } - - static async fromKey(options: CyclopsClientOptions): Promise { - const client = new CyclopsClient(options) - await client.tokenProvider.getToken() - return client - } -} diff --git a/libs/fleet/js-sdk/src/token.ts b/libs/fleet/js-sdk/src/token.ts deleted file mode 100644 index 6b2f88d47e..0000000000 --- a/libs/fleet/js-sdk/src/token.ts +++ /dev/null @@ -1,73 +0,0 @@ -export interface ClientCredentialsTokenProviderOptions { - tokenUrl: string - clientId: string - clientSecret: string - fetch?: typeof fetch - refreshBufferMs?: number -} - -function resolveFetch(customFetch?: typeof fetch): typeof fetch { - const runtimeFetch = customFetch ?? globalThis.fetch - if (typeof runtimeFetch !== "function") { - throw new Error( - "Fetch API is unavailable. @trycua/cyclops requires Node.js 18+ or an injected fetch implementation.", - ) - } - return runtimeFetch -} - -interface TokenResponse { - access_token: string - expires_in?: number -} - -export class ClientCredentialsTokenProvider { - private readonly tokenUrl: string - private readonly clientId: string - private readonly clientSecret: string - private readonly fetch: typeof fetch - private readonly refreshBufferMs: number - private accessToken?: string - private tokenDeadline = 0 - private refreshPromise?: Promise - - constructor(options: ClientCredentialsTokenProviderOptions) { - this.tokenUrl = options.tokenUrl - this.clientId = options.clientId - this.clientSecret = options.clientSecret - this.fetch = resolveFetch(options.fetch) - this.refreshBufferMs = options.refreshBufferMs ?? 30_000 - } - - async getToken(): Promise { - if (this.accessToken && Date.now() < this.tokenDeadline - this.refreshBufferMs) { - return this.accessToken - } - if (!this.refreshPromise) { - this.refreshPromise = this.exchangeToken().finally(() => { - this.refreshPromise = undefined - }) - } - return this.refreshPromise - } - - private async exchangeToken(): Promise { - const response = await this.fetch(this.tokenUrl, { - method: "POST", - body: new URLSearchParams({ - grant_type: "client_credentials", - client_id: this.clientId, - client_secret: this.clientSecret, - }), - }) - if (!response.ok) { - const body = await response.text().catch(() => "") - throw new Error(`${response.status} ${response.statusText}${body ? `: ${body}` : ""}`) - } - - const token = (await response.json()) as TokenResponse - this.accessToken = token.access_token - this.tokenDeadline = Date.now() + Number(token.expires_in ?? 300) * 1000 - return this.accessToken - } -} diff --git a/libs/fleet/js-sdk/test/client.test.js b/libs/fleet/js-sdk/test/client.test.js deleted file mode 100644 index d525bbd18e..0000000000 --- a/libs/fleet/js-sdk/test/client.test.js +++ /dev/null @@ -1,76 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { CyclopsClient } from "../dist/index.js" - -test("generated endpoint uses its generated route and bearer authentication", async () => { - const requests = [] - const fetch = async (input, init = {}) => { - requests.push({ url: String(input), init }) - if (String(input).endsWith("/token")) { - return Response.json({ access_token: "token-1", expires_in: 300 }) - } - return Response.json({ keys: [] }) - } - - const client = new CyclopsClient({ - baseUrl: "https://run.example", - tokenUrl: "https://auth.example/token", - clientId: "key-example", - clientSecret: "secret", - fetch, - }) - - const response = await client.keys.keysList() - - assert.deepEqual(response.data, { keys: [] }) - assert.equal(requests[1].url, "https://run.example/api/keys") - assert.equal(requests[1].init.method, "GET") - assert.equal(requests[1].init.headers.Authorization, "Bearer token-1") -}) - -test("Node global fetch handles token and generated API requests", async () => { - const originalFetch = globalThis.fetch - const requests = [] - globalThis.fetch = async (input, init = {}) => { - requests.push({ url: String(input), init }) - if (String(input).endsWith("/token")) { - return Response.json({ access_token: "global-token", expires_in: 300 }) - } - return Response.json({ keys: [] }) - } - - try { - const client = new CyclopsClient({ - baseUrl: "https://run.example", - tokenUrl: "https://auth.example/token", - clientId: "key-example", - clientSecret: "secret", - }) - - await client.keys.keysList() - - assert.equal(requests.length, 2) - assert.equal(requests[1].init.headers.Authorization, "Bearer global-token") - } finally { - globalThis.fetch = originalFetch - } -}) - -test("client fails clearly when fetch is unavailable", () => { - const originalFetch = globalThis.fetch - globalThis.fetch = undefined - - try { - assert.throws( - () => - new CyclopsClient({ - clientId: "key-example", - clientSecret: "secret", - }), - /requires Node\.js 18\+ or an injected fetch implementation/, - ) - } finally { - globalThis.fetch = originalFetch - } -}) diff --git a/libs/fleet/js-sdk/test/patch-generated-client.test.mjs b/libs/fleet/js-sdk/test/patch-generated-client.test.mjs deleted file mode 100644 index dedcfdb7d9..0000000000 --- a/libs/fleet/js-sdk/test/patch-generated-client.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { mkdtemp, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import test from "node:test"; - -const execFileAsync = promisify(execFile); - -test("patch guard rejects a malformed BillingSummary declaration", async () => { - const directory = await mkdtemp(join(tmpdir(), "cyclops-sdk-patch-")); - const clientPath = join(directory, "cyclops-cs-backend.ts"); - await writeFile(clientPath, ` private customFetch = (...fetchParams: Parameters) =>\n fetch(...fetchParams);\n\nexport interface BillingSummary {\n card: BillingCardSummary | null;\n}\n`); - - await assert.rejects( - execFileAsync(process.execPath, ["scripts/patch-generated-client.mjs", clientPath], { - cwd: new URL("..", import.meta.url), - }), - /BillingSummary card declaration did not match/, - ); -}); diff --git a/libs/fleet/js-sdk/test/token.test.js b/libs/fleet/js-sdk/test/token.test.js deleted file mode 100644 index b2d2f859da..0000000000 --- a/libs/fleet/js-sdk/test/token.test.js +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { ClientCredentialsTokenProvider } from "../dist/index.js" - -test("token provider exchanges credentials and reuses an unexpired token", async () => { - const requests = [] - const provider = new ClientCredentialsTokenProvider({ - tokenUrl: "https://auth.example/token", - clientId: "key-example", - clientSecret: "secret", - fetch: async (input, init = {}) => { - requests.push({ url: String(input), init }) - return Response.json({ access_token: "token-1", expires_in: 300 }) - }, - }) - - assert.equal(await provider.getToken(), "token-1") - assert.equal(await provider.getToken(), "token-1") - assert.equal(requests.length, 1) - assert.equal(requests[0].url, "https://auth.example/token") - assert.equal(requests[0].init.method, "POST") - assert.equal(requests[0].init.body.get("grant_type"), "client_credentials") - assert.equal(requests[0].init.body.get("client_id"), "key-example") - assert.equal(requests[0].init.body.get("client_secret"), "secret") -}) - -test("token provider refreshes inside the configured expiry buffer", async () => { - let exchanges = 0 - const provider = new ClientCredentialsTokenProvider({ - tokenUrl: "https://auth.example/token", - clientId: "key-example", - clientSecret: "secret", - refreshBufferMs: 30_000, - fetch: async () => { - exchanges += 1 - return Response.json({ access_token: `token-${exchanges}`, expires_in: 1 }) - }, - }) - - assert.equal(await provider.getToken(), "token-1") - assert.equal(await provider.getToken(), "token-2") -}) diff --git a/libs/fleet/js-sdk/tsconfig.json b/libs/fleet/js-sdk/tsconfig.json deleted file mode 100644 index b9ac97ec16..0000000000 --- a/libs/fleet/js-sdk/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src", - "skipLibCheck": true - }, - "include": ["src"] -} diff --git a/libs/fleet/nginx.conf b/libs/fleet/nginx.conf index 9f2528448f..ebc52bcbc1 100644 --- a/libs/fleet/nginx.conf +++ b/libs/fleet/nginx.conf @@ -67,8 +67,6 @@ server { # /api/gateway — token-authenticated proxy to per-pool orchestrators # /api/k8s — SPA-authenticated proxy to the kubectl-proxy sidecar # (replaces the old /k8s-api/ nginx block) - # /api/orch — SPA-authenticated proxy to per-namespace orchestrator - # services (replaces the old /orch-api/ nginx block) # /api/batch — OSGym batch driver (submit/poll/results/cancel) # /api/label — same, label-scoped streaming variant # @@ -152,7 +150,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/api/(keys|user-keys|github-trust-policies|gateway|k8s|orch|swagger|batch|label|namespaces|config|chat|billing|state)(/|$) { + location ~ ^/api/(keys|user-keys|github-trust-policies|gateway|k8s|swagger|batch|label|namespaces|config|chat|billing|state)(/|$) { proxy_pass ${CYCLOPS_CS_BACKEND}; proxy_http_version 1.1; proxy_set_header Host $host; @@ -180,9 +178,9 @@ server { return 404; } - # The previous /k8s-api/ and /orch-api/ blocks were removed — both are - # now served by the cyclops-cs-backend Deployment at /api/k8s and - # /api/orch with Keycloak SSO + OPA authorization in front. The + # The previous /k8s-api/ block is now served by the cyclops-cs-backend + # Deployment at /api/k8s with Keycloak SSO + OPA authorization in front. + # The old /orch-api/ route is retired without a replacement. The # kubectl-proxy container moved with the backend (it talks to it on # 127.0.0.1:8001 to inherit the backend pod's ServiceAccount K8s # credentials). diff --git a/libs/fleet/vite.config.ts b/libs/fleet/vite.config.ts index 5bb5899969..6eb47dd498 100644 --- a/libs/fleet/vite.config.ts +++ b/libs/fleet/vite.config.ts @@ -2,10 +2,9 @@ import { defineConfig } from "vite" import react from "@vitejs/plugin-react" import { fileURLToPath, URL } from "node:url" -// /api/k8s and /api/orch are served by the cyclops-cs backend sidecar -// (Keycloak SSO + OPA), which isn't reachable from a laptop. In dev, -// route them through the deployed cyclops-cs Tailscale ingress so the -// in-cluster nginx forwards to the sidecar. +// Backend-owned routes such as /api/k8s are served by cyclops-cs behind +// Keycloak SSO + OPA and aren't reachable from a laptop. In dev, route them +// through the deployed cyclops-cs Tailscale ingress. const ORCH_API = process.env.ORCH_API ?? "https://cyclops-cs.tail204509.ts.net" export default defineConfig({ From ced70e3bc145e0c13dabf77be26e76a43a72545f Mon Sep 17 00:00:00 2001 From: r33drichards Date: Wed, 19 Aug 2026 09:28:28 -0700 Subject: [PATCH 071/117] ci: promote the cua-fleet 0.1.14 wheel set to PyPI (#3255) Repin cd-py-fleet.yml from the 0.1.12 wheel set to 0.1.14, built from trycua/cloud tag cua-fleet-sdk/v0.1.14 (adds ttl_seconds_after_created to OSGymSandboxWarmPoolSpec and ClaimSpec). Updates the version guard, the five matrix wheel filenames + sha256 digests, and the validate-step digest map to the wheels now on wheels.cua.ai. --- .github/workflows/cd-py-fleet.yml | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/cd-py-fleet.yml b/.github/workflows/cd-py-fleet.yml index be555db7b1..936f230c79 100644 --- a/.github/workflows/cd-py-fleet.yml +++ b/.github/workflows/cd-py-fleet.yml @@ -9,7 +9,7 @@ on: version: description: "Canonical cua-fleet version to publish" required: true - default: "0.1.12" + default: "0.1.14" workflow_call: inputs: version: @@ -49,8 +49,8 @@ jobs: exit 1 fi - if [[ "$VERSION" != "0.1.12" ]]; then - echo "::error::This workflow promotes the hash-pinned cua-fleet 0.1.12 wheel set, not $VERSION." + if [[ "$VERSION" != "0.1.14" ]]; then + echo "::error::This workflow promotes the hash-pinned cua-fleet 0.1.14 wheel set, not $VERSION." exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -65,32 +65,32 @@ jobs: include: - platform: linux-x86_64 runner: ubuntu-22.04 - wheel: cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl - sha256: d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a + wheel: cua_fleet-0.1.14-py3-none-manylinux_2_34_x86_64.whl + sha256: 2dc70e98b3e8c691bf0fff0494b0a85d2405e872f1ca99dbfa2680473cea565b native_library: libcyclops_sdk.so audit: auditwheel - platform: linux-aarch64 runner: ubuntu-24.04-arm - wheel: cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl - sha256: 0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973 + wheel: cua_fleet-0.1.14-py3-none-manylinux_2_34_aarch64.whl + sha256: d6ea25902cf9ffc89779530b6ae7cff5413383ea7dc3c632a4fb47e00699a6d4 native_library: libcyclops_sdk.so audit: auditwheel - platform: macos-x86_64 runner: macos-15-intel - wheel: cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl - sha256: d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75 + wheel: cua_fleet-0.1.14-py3-none-macosx_10_12_x86_64.whl + sha256: d370f83773574da8edcca080e9d86aec8eb7ed758d6c551b900482a8575f6810 native_library: libcyclops_sdk.dylib audit: delocate - platform: macos-arm64 runner: macos-14 - wheel: cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl - sha256: 82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2 + wheel: cua_fleet-0.1.14-py3-none-macosx_11_0_arm64.whl + sha256: e19784c0ce8aa2d9a77bf096fc6ff10e990842f05c67bdfb96a16ecd5e7123e2 native_library: libcyclops_sdk.dylib audit: delocate - platform: windows-x86_64 runner: windows-latest - wheel: cua_fleet-0.1.12-py3-none-win_amd64.whl - sha256: c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75 + wheel: cua_fleet-0.1.14-py3-none-win_amd64.whl + sha256: 3e73327b7c99dc95a4b4194628d3575a8707cab77d6929ed1bf34a82192a4464 native_library: cyclops_sdk.dll audit: none steps: @@ -246,11 +246,11 @@ jobs: version, dist_directory = sys.argv[1:] expected = { - f"cua_fleet-{version}-py3-none-manylinux_2_34_x86_64.whl": "d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a", - f"cua_fleet-{version}-py3-none-manylinux_2_34_aarch64.whl": "0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973", - f"cua_fleet-{version}-py3-none-macosx_10_12_x86_64.whl": "d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75", - f"cua_fleet-{version}-py3-none-macosx_11_0_arm64.whl": "82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2", - f"cua_fleet-{version}-py3-none-win_amd64.whl": "c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75", + f"cua_fleet-{version}-py3-none-manylinux_2_34_x86_64.whl": "2dc70e98b3e8c691bf0fff0494b0a85d2405e872f1ca99dbfa2680473cea565b", + f"cua_fleet-{version}-py3-none-manylinux_2_34_aarch64.whl": "d6ea25902cf9ffc89779530b6ae7cff5413383ea7dc3c632a4fb47e00699a6d4", + f"cua_fleet-{version}-py3-none-macosx_10_12_x86_64.whl": "d370f83773574da8edcca080e9d86aec8eb7ed758d6c551b900482a8575f6810", + f"cua_fleet-{version}-py3-none-macosx_11_0_arm64.whl": "e19784c0ce8aa2d9a77bf096fc6ff10e990842f05c67bdfb96a16ecd5e7123e2", + f"cua_fleet-{version}-py3-none-win_amd64.whl": "3e73327b7c99dc95a4b4194628d3575a8707cab77d6929ed1bf34a82192a4464", } wheels = {wheel.name: wheel for wheel in Path(dist_directory).glob("*.whl")} assert set(wheels) == set(expected), (set(wheels), set(expected)) From d5037005b90c6866db84fd20132b709be4d227dd Mon Sep 17 00:00:00 2001 From: r33drichards Date: Wed, 19 Aug 2026 09:59:59 -0700 Subject: [PATCH 072/117] feat(cua-sandbox): expose creation TTL for pools and claims (0.4.2) (#3256) * feat(cua-sandbox): expose creation TTL for pools and claims (0.4.2) Bump cua-fleet to 0.1.14 (first wheel set carrying ttl_seconds_after_created on OSGymSandboxWarmPoolSpec and ClaimSpec) and surface the field through the sandbox SDK: - Pool.apply(ttl_seconds_after_created=) reaps the pool by creation age - pool.claim()/create_claim(ttl_seconds_after_created=) derive a claim spec from the pool's template ref; combining it with an explicit spec= raises ValueError - sync facade forwards both; values validated to the schema's u32 range - document both in the create-pool-with-python guide, including the reconcile-does-not-reset-age and pool-expiry-drains-sandboxes caveats * test(cua-sandbox): update stale fleet version pins in packaging tests test_fleet_sdk_packaging and test_fleet_sdk_distribution still asserted cua-fleet 0.1.11 (stale since the 0.1.12 train); pin them to the 0.1.14 bundle this branch ships. --- .../sandbox/create-pool-with-python.mdx | 51 +++++++++++ libs/python/cua-sandbox/cua_sandbox/pool.py | 31 ++++++- .../cua-sandbox/cua_sandbox/sync/__init__.py | 9 +- .../cua_sandbox/transport/fleet_cloud.py | 19 ++++ libs/python/cua-sandbox/pyproject.toml | 4 +- .../tests/test_fleet_cloud_transport.py | 29 +++++++ .../tests/test_fleet_sdk_distribution.py | 2 +- .../tests/test_fleet_sdk_packaging.py | 4 +- libs/python/cua-sandbox/tests/test_pool.py | 86 +++++++++++++++++++ libs/python/cua-sandbox/uv.lock | 18 ++-- 10 files changed, 237 insertions(+), 16 deletions(-) diff --git a/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx b/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx index 667cae1340..75e7f746b1 100644 --- a/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx +++ b/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx @@ -141,6 +141,57 @@ Each field is optional (pass `None` to accept the server default). With `min_pool_size=0` the pool scales to zero when no claims are held, so the first claim after an idle period cold-starts a sandbox. +## Expire pools and claims automatically + +Pools and claims accept an optional creation-age TTL. Pass +`ttl_seconds_after_created=` (requires `cua-sandbox>=0.4.2`) and the resource +is deleted once it has existed that many seconds, whether or not it is in use: + +```python +# Delete the whole pool 24 hours after it was first created. +pool = await Pool.apply( + Image.from_registry(IMAGE), + name=POOL_NAME, + replicas=1, + ttl_seconds_after_created=86400, +) + +# Delete this claim and its sandbox one hour after the claim was created. +async with pool.claim( + name=CLAIM_NAME, + ttl_seconds_after_created=3600, +) as sandbox: + ... +``` + +The clock starts at the resource's original creation, not its last use: +re-running `Pool.apply()` reconciles the existing pool without resetting its +age, and reconnecting to a named claim keeps the deadline set when the claim +was first created. Without the argument nothing is reaped — pools and claims +live until you delete them. + +A claim TTL fills in the claim's lifecycle shutdown time with a `Delete` +policy; a claim that already carries an explicit lifecycle keeps it. When you +build a claim spec by hand, put the TTL inside it — passing both +`spec=` and `ttl_seconds_after_created=` raises `ValueError`: + +```python +spec = ClaimSpec( + sandbox_template_ref=pool.resource.spec.sandbox_template_ref, + warmpool=None, + bind_deadline=None, + lifecycle=None, + ttl_seconds_after_created=3600, +) +async with pool.claim(spec=spec) as sandbox: + ... +``` + +Treat a pool TTL that can expire while claims are held as a capacity event, +not just cleanup: deleting the pool drains its sandboxes, and a claim that +outlives its pool destroys its sandbox on release instead of returning it to +the pool. + ## Choose pool and claim names The defaults use the same value for the pool and claim. Override either name diff --git a/libs/python/cua-sandbox/cua_sandbox/pool.py b/libs/python/cua-sandbox/cua_sandbox/pool.py index e8e9e55951..849366659f 100644 --- a/libs/python/cua-sandbox/cua_sandbox/pool.py +++ b/libs/python/cua-sandbox/cua_sandbox/pool.py @@ -14,6 +14,7 @@ _canonicalize_pool_access_denied, _FleetClient, _pool_access_denied, + validate_ttl_seconds_after_created, ) from fleet_sdk import ( Claim, @@ -255,6 +256,7 @@ async def apply( memory_mb: int | None = None, services: dict[str, int] | None = None, autoscaling: WarmPoolAutoscaling | None = None, + ttl_seconds_after_created: int | None = None, ) -> "Pool": if not isinstance(name, str) or not name: raise ValueError( @@ -274,6 +276,7 @@ async def apply( memory_mb=memory_mb, services=effective_services, autoscaling=autoscaling, + ttl_seconds_after_created=ttl_seconds_after_created, ) pool = await cls.reconcile(transport._pool_request()) try: @@ -300,9 +303,32 @@ async def delete(self) -> None: finally: await client.close() + def _claim_spec( + self, spec: ClaimSpec | None, ttl_seconds_after_created: int | None + ) -> ClaimSpec | None: + if ttl_seconds_after_created is None: + return spec + if spec is not None: + raise ValueError( + "pass ttl_seconds_after_created inside spec when supplying an explicit ClaimSpec" + ) + validate_ttl_seconds_after_created(ttl_seconds_after_created) + return ClaimSpec( + sandbox_template_ref=self._resource.spec.sandbox_template_ref, + warmpool=None, + bind_deadline=None, + lifecycle=None, + ttl_seconds_after_created=ttl_seconds_after_created, + ) + async def create_claim( - self, *, spec: ClaimSpec | None = None, name: str | None = None + self, + *, + spec: ClaimSpec | None = None, + name: str | None = None, + ttl_seconds_after_created: int | None = None, ) -> _ClaimHandle: + spec = self._claim_spec(spec, ttl_seconds_after_created) request = CreateClaimRequest(pool=self._resource, spec=spec, name=name) client = _FleetClient() try: @@ -322,7 +348,10 @@ def claim( name: str | None = None, service: str = "server", time_to_start: float | None = None, + ttl_seconds_after_created: int | None = None, ) -> _ClaimResult[Sandbox]: + spec = self._claim_spec(spec, ttl_seconds_after_created) + async def acquire() -> Sandbox: client = _FleetClient() claim: Any = None diff --git a/libs/python/cua-sandbox/cua_sandbox/sync/__init__.py b/libs/python/cua-sandbox/cua_sandbox/sync/__init__.py index d797d028c5..3a0da5c36d 100644 --- a/libs/python/cua-sandbox/cua_sandbox/sync/__init__.py +++ b/libs/python/cua-sandbox/cua_sandbox/sync/__init__.py @@ -132,6 +132,7 @@ def apply( memory_mb: int | None = None, services: dict[str, int] | None = None, autoscaling: WarmPoolAutoscaling | None = None, + ttl_seconds_after_created: int | None = None, ) -> "Pool": """Synchronously apply an image-backed Fleet pool.""" return cls( @@ -144,6 +145,7 @@ def apply( memory_mb=memory_mb, services=services, autoscaling=autoscaling, + ttl_seconds_after_created=ttl_seconds_after_created, ) ) ) @@ -160,10 +162,15 @@ def claim( name: str | None = None, service: str = "server", time_to_start: float | None = None, + ttl_seconds_after_created: int | None = None, ) -> Iterator[_SyncProxy]: """Synchronously claim a sandbox and release it on exit.""" context = self._async_pool.claim( - spec=spec, name=name, service=service, time_to_start=time_to_start + spec=spec, + name=name, + service=service, + time_to_start=time_to_start, + ttl_seconds_after_created=ttl_seconds_after_created, ) sandbox = _run(context.__aenter__()) try: diff --git a/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py b/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py index 0389ce11cd..d8504393b3 100644 --- a/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py +++ b/libs/python/cua-sandbox/cua_sandbox/transport/fleet_cloud.py @@ -406,6 +406,18 @@ def _needs_ecr_pull_secret(image: "str | None") -> bool: return _ECR_HOST_MARKER in host and host.endswith(_ECR_HOST_SUFFIX) +_TTL_SECONDS_MAX = 2**32 - 1 + + +def validate_ttl_seconds_after_created(value: "int | None") -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= _TTL_SECONDS_MAX + ): + raise ValueError( + f"ttl_seconds_after_created must be an integer between 0 and {_TTL_SECONDS_MAX}" + ) + + class FleetCloudTransport(FleetTransport): """Provision image-backed pools or claim pre-created pools through Fleet.""" @@ -426,6 +438,7 @@ def __init__( replicas: int = 1, services: Mapping[str, int] | None = None, autoscaling: Optional[WarmPoolAutoscaling] = None, + ttl_seconds_after_created: Optional[int] = None, ) -> None: if ( isinstance(server_port, bool) @@ -474,6 +487,7 @@ def __init__( raise ValueError( "autoscaling.min_pool_size must not exceed autoscaling.max_pool_size" ) + validate_ttl_seconds_after_created(ttl_seconds_after_created) self._image = image self._name = name self._explicit_pool = pool_name is not None @@ -488,6 +502,7 @@ def __init__( self._replicas = replicas self._services = dict(services) if services is not None else None self._autoscaling = autoscaling + self._ttl_seconds_after_created = ttl_seconds_after_created self._provisioned = False self._owns_resources = image is not None or create_claim self._template: Any = None @@ -724,6 +739,10 @@ def _pool_request(self) -> CreatePoolRequest: ) if self._autoscaling is not None: pool_spec_builder = pool_spec_builder.autoscaling(self._autoscaling) + if self._ttl_seconds_after_created is not None: + pool_spec_builder = pool_spec_builder.ttl_seconds_after_created( + self._ttl_seconds_after_created + ) return ( CreatePoolRequestBuilder() .namespace(self._pool_name) diff --git a/libs/python/cua-sandbox/pyproject.toml b/libs/python/cua-sandbox/pyproject.toml index 513ea75863..c568e1d353 100644 --- a/libs/python/cua-sandbox/pyproject.toml +++ b/libs/python/cua-sandbox/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cua-sandbox" -version = "0.4.1" +version = "0.4.2" description = "CUA Sandbox — ephemeral and persistent sandboxed computer environments" readme = "README.md" license = "MIT" @@ -29,7 +29,7 @@ requires-python = ">=3.11,<3.14" dependencies = [ "cua-core>=0.3.0,<0.4.0", "cua-auto>=0.1.2", - "cua-fleet==0.1.12", + "cua-fleet==0.1.14", "websockets>=12.0", "httpx>=0.27.0", "oras>=0.2.40", diff --git a/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py b/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py index beaa05626e..45191cf318 100644 --- a/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py +++ b/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py @@ -139,6 +139,35 @@ def test_pool_request_accepts_partial_autoscaling_bounds(): assert request.spec.autoscaling == autoscaling +def test_pool_request_carries_the_requested_creation_ttl(): + request = FleetCloudTransport( + image=Image.from_registry("registry.example/workspace@sha256:abc"), + name="demo", + ttl_seconds_after_created=3600, + )._pool_request() + + assert request.spec.ttl_seconds_after_created == 3600 + + +def test_pool_request_leaves_creation_ttl_unset_by_default(): + request = FleetCloudTransport( + image=Image.from_registry("registry.example/workspace@sha256:abc"), + name="demo", + )._pool_request() + + assert request.spec.ttl_seconds_after_created is None + + +@pytest.mark.parametrize("ttl", [-1, True, "3600", 1.5, 2**32]) +def test_transport_rejects_invalid_creation_ttl(ttl): + with pytest.raises(ValueError, match="ttl_seconds_after_created"): + FleetCloudTransport( + image=Image.from_registry("registry.example/workspace@sha256:abc"), + name="demo", + ttl_seconds_after_created=ttl, + ) + + def test_transport_rejects_untyped_autoscaling(): with pytest.raises(TypeError, match="WarmPoolAutoscaling"): FleetCloudTransport( diff --git a/libs/python/cua-sandbox/tests/test_fleet_sdk_distribution.py b/libs/python/cua-sandbox/tests/test_fleet_sdk_distribution.py index 12925d1cd8..313dce7042 100644 --- a/libs/python/cua-sandbox/tests/test_fleet_sdk_distribution.py +++ b/libs/python/cua-sandbox/tests/test_fleet_sdk_distribution.py @@ -10,6 +10,6 @@ def test_fleet_sdk_is_provided_by_published_fleet_distribution(): distribution_root = Path(cua_fleet_distribution.locate_file(".")).resolve() binding_path = Path(fleet_sdk.__file__).resolve() - assert cua_fleet_distribution.version == "0.1.11" + assert cua_fleet_distribution.version == "0.1.14" assert "fleet_sdk/__init__.py" in installed_files assert binding_path.is_relative_to(distribution_root) diff --git a/libs/python/cua-sandbox/tests/test_fleet_sdk_packaging.py b/libs/python/cua-sandbox/tests/test_fleet_sdk_packaging.py index 0f6fcdd7fb..76c2a43239 100644 --- a/libs/python/cua-sandbox/tests/test_fleet_sdk_packaging.py +++ b/libs/python/cua-sandbox/tests/test_fleet_sdk_packaging.py @@ -18,7 +18,7 @@ def test_declares_published_fleet_without_a_direct_train_dependency(self): project = tomllib.load(pyproject_file) dependencies = project["project"]["dependencies"] - self.assertIn("cua-fleet==0.1.11", dependencies) + self.assertIn("cua-fleet==0.1.14", dependencies) self.assertFalse(any(dependency.startswith("cua-train") for dependency in dependencies)) self.assertNotIn("cua-fleet", project["tool"]["uv"]["sources"]) self.assertNotIn("cua-train", project["tool"]["uv"]["sources"]) @@ -49,7 +49,7 @@ def test_lock_uses_the_published_fleet_bundle(self): self.assertNotIn("cua-train", sandbox_dependencies) self.assertIn("cua-fleet", sandbox_requires_dist) self.assertNotIn("cua-train", sandbox_requires_dist) - self.assertEqual(packages["cua-fleet"]["version"], "0.1.11") + self.assertEqual(packages["cua-fleet"]["version"], "0.1.14") self.assertEqual( packages["cua-fleet"]["source"], {"registry": "https://wheels.cua.ai/simple"} ) diff --git a/libs/python/cua-sandbox/tests/test_pool.py b/libs/python/cua-sandbox/tests/test_pool.py index 637f5dc2f1..deebefb6b3 100644 --- a/libs/python/cua-sandbox/tests/test_pool.py +++ b/libs/python/cua-sandbox/tests/test_pool.py @@ -626,6 +626,51 @@ async def test_create_claim_returns_a_serializable_lease(monkeypatch): assert _ClaimHandle.from_dict(lease.to_dict()).to_dict() == lease.to_dict() +@pytest.mark.asyncio +async def test_create_claim_forwards_creation_ttl_in_a_derived_spec(monkeypatch): + reconcile_client = FakeFleetClient() + claim_client = FakeFleetClient() + clients = iter([reconcile_client, claim_client]) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(clients)) + pool = await Pool.reconcile(pool_request()) + + await pool.create_claim(ttl_seconds_after_created=1800) + + spec = claim_client.claims[0].spec + assert spec.ttl_seconds_after_created == 1800 + assert spec.sandbox_template_ref is pool.resource.spec.sandbox_template_ref + assert spec.warmpool is None + assert spec.bind_deadline is None + assert spec.lifecycle is None + + +@pytest.mark.asyncio +async def test_create_claim_rejects_ttl_alongside_an_explicit_spec(monkeypatch): + reconcile_client = FakeFleetClient() + clients = iter([reconcile_client, FakeFleetClient()]) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(clients)) + pool = await Pool.reconcile(pool_request()) + + with pytest.raises(ValueError, match="inside spec"): + await pool.create_claim(spec=SimpleNamespace(), ttl_seconds_after_created=1800) + + +@pytest.mark.asyncio +async def test_pool_claim_forwards_creation_ttl_in_a_derived_spec(monkeypatch): + reconcile_client = FakeFleetClient() + claim_client = FakeFleetClient() + clients = iter([reconcile_client, claim_client]) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(clients)) + pool = await Pool.reconcile(pool_request()) + + async with pool.claim(ttl_seconds_after_created=900): + pass + + spec = claim_client.claims[0].spec + assert spec.ttl_seconds_after_created == 900 + assert spec.sandbox_template_ref is pool.resource.spec.sandbox_template_ref + + @pytest.mark.asyncio async def test_lease_wait_connects_to_the_named_service_and_caches_the_bind(monkeypatch): client = FakeFleetClient() @@ -946,6 +991,47 @@ def test_sync_pool_apply_forwards_autoscaling(monkeypatch): assert clients[0].reconciled[0].spec.autoscaling == autoscaling +@pytest.mark.asyncio +async def test_pool_apply_forwards_creation_ttl_to_the_pool_request(monkeypatch): + clients = [FakeFleetClient() for _ in range(2)] + iterator = iter(clients) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(iterator)) + + await Pool.apply( + Image.from_registry("registry.example/workspace:latest"), + name="workspace", + ttl_seconds_after_created=86400, + ) + + assert clients[0].reconciled[0].spec.ttl_seconds_after_created == 86400 + + +@pytest.mark.asyncio +async def test_pool_apply_without_creation_ttl_leaves_the_pool_unreaped(monkeypatch): + clients = [FakeFleetClient() for _ in range(2)] + iterator = iter(clients) + monkeypatch.setattr("cua_sandbox.pool._FleetClient", lambda: next(iterator)) + + await Pool.apply( + Image.from_registry("registry.example/workspace:latest"), + name="workspace", + ) + + assert clients[0].reconciled[0].spec.ttl_seconds_after_created is None + + +@pytest.mark.asyncio +async def test_pool_apply_rejects_invalid_creation_ttl(monkeypatch): + monkeypatch.setattr("cua_sandbox.pool._FleetClient", FakeFleetClient) + + with pytest.raises(ValueError, match="ttl_seconds_after_created"): + await Pool.apply( + Image.from_registry("registry.example/workspace:latest"), + name="workspace", + ttl_seconds_after_created=-1, + ) + + def _forbidden(operation: str) -> SdkError.Status: return SdkError.Status(operation=operation, status=403, body="k8s request is not allowed") diff --git a/libs/python/cua-sandbox/uv.lock b/libs/python/cua-sandbox/uv.lock index 979e38095f..1daa96aebc 100644 --- a/libs/python/cua-sandbox/uv.lock +++ b/libs/python/cua-sandbox/uv.lock @@ -528,19 +528,19 @@ dev = [{ name = "pytest", specifier = ">=8.3.5" }] [[package]] name = "cua-fleet" -version = "0.1.12" +version = "0.1.14" source = { registry = "https://wheels.cua.ai/simple" } wheels = [ - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.12-py3-none-win_amd64.whl", hash = "sha256:c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d370f83773574da8edcca080e9d86aec8eb7ed758d6c551b900482a8575f6810" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e19784c0ce8aa2d9a77bf096fc6ff10e990842f05c67bdfb96a16ecd5e7123e2" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:d6ea25902cf9ffc89779530b6ae7cff5413383ea7dc3c632a4fb47e00699a6d4" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:2dc70e98b3e8c691bf0fff0494b0a85d2405e872f1ca99dbfa2680473cea565b" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-win_amd64.whl", hash = "sha256:3e73327b7c99dc95a4b4194628d3575a8707cab77d6929ed1bf34a82192a4464" }, ] [[package]] name = "cua-sandbox" -version = "0.4.1" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "cua-auto" }, @@ -577,7 +577,7 @@ dev = [ requires-dist = [ { name = "cua-auto", specifier = ">=0.1.2" }, { name = "cua-core", specifier = ">=0.3.0,<0.4.0" }, - { name = "cua-fleet", specifier = "==0.1.12" }, + { name = "cua-fleet", specifier = "==0.1.14" }, { name = "grpcio", specifier = "==1.78.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "oras", specifier = ">=0.2.40" }, @@ -620,7 +620,7 @@ name = "ewmhlib" version = "0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "python-xlib" }, { name = "typing-extensions" }, ] wheels = [ From 78673aaa3134aa4a06d190f02e231e01236716a3 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 19 Aug 2026 10:11:54 -0700 Subject: [PATCH 073/117] fix(computer-server): fail closed for host tools in VNC mode * fix(computer-server): fail closed for host tools in VNC mode * test(computer-server): preserve direct MCP module loading * test(computer-server): cover VNC transport schemas * test(computer-server): keep VNC coverage headless-safe * fix(computer-server): refuse direct agents in VNC mode --- .../computer_server/backend_policy.py | 104 +++++++++ .../computer_server/handlers/factory.py | 12 +- .../computer_server/handlers/vnc.py | 13 +- .../computer-server/computer_server/main.py | 101 ++++++-- .../computer_server/mcp_server.py | 33 ++- .../tests/test_vnc_backend_scope.py | 216 ++++++++++++++++++ 6 files changed, 449 insertions(+), 30 deletions(-) create mode 100644 libs/python/computer-server/computer_server/backend_policy.py create mode 100644 libs/python/computer-server/tests/test_vnc_backend_scope.py diff --git a/libs/python/computer-server/computer_server/backend_policy.py b/libs/python/computer-server/computer_server/backend_policy.py new file mode 100644 index 0000000000..fad8085483 --- /dev/null +++ b/libs/python/computer-server/computer_server/backend_policy.py @@ -0,0 +1,104 @@ +"""Backend-specific exposure policy for computer-server ingress surfaces.""" + +import os +from typing import Any, Dict, Mapping, TypeVar + +VNC_UNSUPPORTED_CODE = "unsupported_in_vnc" +VNC_UNSUPPORTED_ERROR = ( + "This operation is unavailable with the VNC backend because VNC only " + "provides remote screen, pointer, scroll, and keyboard control." +) + +# This is deliberately an allowlist. New commands and MCP tools stay hidden in +# VNC mode until they are proven to operate through the remote VNC connection. +VNC_REMOTE_COMMANDS = frozenset( + { + "version", + "mouse_down", + "mouse_up", + "left_click", + "right_click", + "double_click", + "move_cursor", + "drag_to", + "drag", + "key_down", + "key_up", + "type_text", + "press_key", + "hotkey", + "scroll", + "scroll_down", + "scroll_up", + "scroll_direction", + "screenshot", + "get_cursor_position", + "get_screen_size", + } +) + +VNC_REMOTE_MCP_TOOLS = frozenset( + { + "computer_screenshot", + "computer_get_screen_size", + "computer_get_cursor_position", + "computer_click", + "computer_double_click", + "computer_move", + "computer_drag", + "computer_scroll", + "computer_mouse_down", + "computer_mouse_up", + "computer_type", + "computer_press_key", + "computer_hotkey", + "computer_key_down", + "computer_key_up", + } +) + + +def configured_backend() -> str: + """Return the effective backend using the same selection rule as the factory.""" + + backend = os.environ.get("CUA_BACKEND", "native").strip().lower() + if backend == "vnc" or os.environ.get("CUA_VNC_HOST"): + return "vnc" + return backend + + +def is_vnc_backend() -> bool: + return configured_backend() == "vnc" + + +def vnc_unsupported_result() -> Dict[str, Any]: + """Return the stable refusal shared by defensive handler fallbacks.""" + + return { + "success": False, + "code": VNC_UNSUPPORTED_CODE, + "error": VNC_UNSUPPORTED_ERROR, + } + + +RegistryValue = TypeVar("RegistryValue") + + +def exposed_command_registry( + registry: Mapping[str, RegistryValue], +) -> Dict[str, RegistryValue]: + """Return the commands safe to advertise for the configured backend.""" + + if not is_vnc_backend(): + return dict(registry) + return {name: value for name, value in registry.items() if name in VNC_REMOTE_COMMANDS} + + +class VNCUnavailableHandler: + """Fail-closed placeholder for capabilities VNC cannot address remotely.""" + + def __getattr__(self, _name: str): + async def refuse(*_args, **_kwargs) -> Dict[str, Any]: + return vnc_unsupported_result() + + return refuse diff --git a/libs/python/computer-server/computer_server/handlers/factory.py b/libs/python/computer-server/computer_server/handlers/factory.py index 0ef4bfcbe5..ef53987016 100644 --- a/libs/python/computer-server/computer_server/handlers/factory.py +++ b/libs/python/computer-server/computer_server/handlers/factory.py @@ -5,6 +5,7 @@ from computer_server.diorama.base import BaseDioramaHandler +from ..backend_policy import VNCUnavailableHandler, configured_backend from ..utils.helpers import get_current_os from .base import ( BaseAccessibilityHandler, @@ -64,9 +65,9 @@ def create_handlers() -> HandlerTuple: NotImplementedError: If the current OS is not supported RuntimeError: If unable to determine the current OS """ - backend = os.environ.get("CUA_BACKEND", "native").strip().lower() + backend = configured_backend() vnc_host = os.environ.get("CUA_VNC_HOST") - if backend == "vnc" or vnc_host: + if backend == "vnc": if not vnc_host: raise RuntimeError( "CUA_VNC_HOST must be set when using VNC backend " @@ -76,14 +77,15 @@ def create_handlers() -> HandlerTuple: vnc_port = int(os.environ.get("CUA_VNC_PORT", "5900")) vnc_password = os.environ.get("CUA_VNC_PASSWORD", "") + unavailable = VNCUnavailableHandler() logger.info(f"Using VNC backend → {vnc_host}:{vnc_port}") return ( VNCAccessibilityHandler(), VNCAutomationHandler(host=vnc_host, port=vnc_port, password=vnc_password), BaseDioramaHandler(), - GenericFileHandler(), - GenericDesktopHandler(), - GenericWindowHandler(), + unavailable, + unavailable, + unavailable, ) if backend not in {"native", "cua-driver"}: raise RuntimeError("CUA_BACKEND must be native, vnc, or cua-driver") diff --git a/libs/python/computer-server/computer_server/handlers/vnc.py b/libs/python/computer-server/computer_server/handlers/vnc.py index 7285907b82..9b0de5da99 100644 --- a/libs/python/computer-server/computer_server/handlers/vnc.py +++ b/libs/python/computer-server/computer_server/handlers/vnc.py @@ -24,6 +24,7 @@ from io import BytesIO from typing import Any, Dict, List, Optional, Tuple +from ..backend_policy import vnc_unsupported_result from .base import BaseAccessibilityHandler, BaseAutomationHandler logger = logging.getLogger(__name__) @@ -563,7 +564,17 @@ async def get_cursor_position(self) -> Dict[str, Any]: except Exception as e: return {"success": False, "error": str(e)} - # Clipboard and run_command inherited from BaseAutomationHandler + # VNC has no clipboard or shell channel. Override the base class's local + # fallbacks so even a stale/direct caller cannot mutate the server host. + + async def copy_to_clipboard(self) -> Dict[str, Any]: + return vnc_unsupported_result() + + async def set_clipboard(self, text: str) -> Dict[str, Any]: + return vnc_unsupported_result() + + async def run_command(self, command: str, timeout: Optional[float] = None) -> Dict[str, Any]: + return vnc_unsupported_result() # --------------------------------------------------------------------------- diff --git a/libs/python/computer-server/computer_server/main.py b/libs/python/computer-server/computer_server/main.py index c068f53090..25f2d0ebeb 100644 --- a/libs/python/computer-server/computer_server/main.py +++ b/libs/python/computer-server/computer_server/main.py @@ -26,6 +26,11 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse +from .backend_policy import ( + exposed_command_registry, + is_vnc_backend, + vnc_unsupported_result, +) from .browser import get_browser_manager from .handlers.factory import OS_TYPE, HandlerFactory @@ -193,6 +198,54 @@ async def _reject_websocket(cls, receive, send, status_code): app.add_middleware(UnavailableWithoutContainerMiddleware) + +class VNCBackendScopeGuard: + """Refuse host-only HTTP and WebSocket surfaces in remote VNC mode.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + path = scope.get("path", "") + host_scoped = path in {"/playwright_exec", "/responses", "/pty"} or path.startswith("/pty/") + if not is_vnc_backend() or not host_scoped: + await self.app(scope, receive, send) + return + + if scope.get("type") == "websocket": + event = await receive() + if event.get("type") != "websocket.connect": + return + await send({"type": "websocket.accept"}) + await send( + { + "type": "websocket.send", + "text": json.dumps(vnc_unsupported_result()), + } + ) + await send({"type": "websocket.close", "code": 1008}) + return + + if scope.get("type") == "http": + body = json.dumps(vnc_unsupported_result()).encode() + await send( + { + "type": "http.response.start", + "status": 409, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": body}) + return + + await self.app(scope, receive, send) + + +app.add_middleware(VNCBackendScopeGuard) + # CORS configuration origins = ["*"] app.add_middleware( @@ -417,6 +470,12 @@ async def _scroll_direction_handler(direction: str, clicks: int = 1) -> Dict[str if hasattr(automation_handler, "escalate_capture_scope"): handlers["escalate_capture_scope"] = automation_handler.escalate_capture_scope +handlers = exposed_command_registry(handlers) +if is_vnc_backend(): + COMMAND_ALIASES = { + alias: canonical for alias, canonical in COMMAND_ALIASES.items() if canonical in handlers + } + class AuthenticationManager: def __init__(self): @@ -829,6 +888,27 @@ async def _require_auth( raise HTTPException(status_code=401, detail="Authentication failed") +class DirectComputerInterface: + """BrowserTool-compatible interface without host leakage in VNC mode.""" + + def __init__(self, automation_handler, browser_manager): + self._auto = automation_handler + self._browser = None if is_vnc_backend() else browser_manager + + @property + def interface(self): + """Return the target-scoped automation handler.""" + + return self._auto + + async def playwright_exec(self, command: str, params: dict) -> dict: + """Execute a browser command only when it addresses the local backend.""" + + if self._browser is None: + return vnc_unsupported_result() + return await self._browser.execute_command(command, params) + + # --------------------------------------------------------------------------- # PTY endpoints # --------------------------------------------------------------------------- @@ -1141,31 +1221,10 @@ def _to_messages(data: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]] # and delegates to our existing automation/file/accessibility handlers. from cua_agent.computers import AsyncComputerHandler # runtime-checkable Protocol - class DirectComputerInterface: - """Interface wrapper providing BrowserTool compatibility. - - Matches the same interface shape as Computer.interface so BrowserTool - works identically with both Computer (cloud) and DirectComputer (local). - """ - - def __init__(self, automation_handler, browser_manager): - self._auto = automation_handler - self._browser = browser_manager - - @property - def interface(self): - """Return automation handler for hotkey, move_cursor, etc.""" - return self._auto - - async def playwright_exec(self, command: str, params: dict) -> dict: - """Execute browser command via browser_manager.""" - return await self._browser.execute_command(command, params) - class DirectComputer(AsyncComputerHandler): def __init__(self): # use module-scope handler singletons created by HandlerFactory self._auto = automation_handler - self._file = file_handler self._access = accessibility_handler # Create interface for BrowserTool compatibility self._interface = DirectComputerInterface(automation_handler, get_browser_manager()) diff --git a/libs/python/computer-server/computer_server/mcp_server.py b/libs/python/computer-server/computer_server/mcp_server.py index 57bff0aa2e..92132cca19 100644 --- a/libs/python/computer-server/computer_server/mcp_server.py +++ b/libs/python/computer-server/computer_server/mcp_server.py @@ -15,6 +15,7 @@ import sys from typing import Any, Dict, List, Optional, Tuple +from computer_server.backend_policy import VNC_REMOTE_MCP_TOOLS, is_vnc_backend from fastmcp import FastMCP from fastmcp.utilities.types import Image @@ -117,19 +118,45 @@ def create_mcp_server() -> FastMCP: """ mcp = FastMCP( name="cua-computer-server", - instructions="""You are connected to a computer control server that provides low-level + instructions=( + """You are connected to a remote VNC computer control server. It provides only + screen, pointer, scroll, and keyboard operations that act on the VNC target. + + Always take a screenshot first to see the current state before performing actions. + After performing actions, take another screenshot to verify the result.""" + if is_vnc_backend() + else """You are connected to a computer control server that provides low-level primitives for interacting with a desktop computer. You can take screenshots, click, type text, press keys, scroll, manage windows, read/write files, and run commands. Always take a screenshot first to see the current state before performing actions. - After performing actions, take another screenshot to verify the result.""", + After performing actions, take another screenshot to verify the result.""" + ), ) + original_tool = mcp.tool + + def register_tool(function=None, **kwargs): + """Register only tools proven to address the remote target in VNC mode.""" + + def register(candidate): + if not is_vnc_backend() or candidate.__name__ in VNC_REMOTE_MCP_TOOLS: + return original_tool(candidate, **kwargs) + return candidate + + if function is None: + return register + return register(function) + + # Keep every decorator below on the same fail-closed registration path, + # including future tools added to this function. + mcp.tool = register_tool + # ============================================================ # SCREEN & MOUSE ACTIONS # ============================================================ - @mcp.tool + @register_tool async def computer_screenshot() -> Image: """ Capture a screenshot of the current screen. diff --git a/libs/python/computer-server/tests/test_vnc_backend_scope.py b/libs/python/computer-server/tests/test_vnc_backend_scope.py new file mode 100644 index 0000000000..9aa4f6926a --- /dev/null +++ b/libs/python/computer-server/tests/test_vnc_backend_scope.py @@ -0,0 +1,216 @@ +"""Fail-closed transport coverage for the remote VNC backend.""" + +import os +from unittest.mock import AsyncMock, Mock + +import pytest +from computer_server.backend_policy import ( + VNC_REMOTE_COMMANDS, + VNC_REMOTE_MCP_TOOLS, + VNC_UNSUPPORTED_CODE, + VNCUnavailableHandler, + exposed_command_registry, +) +from computer_server.mcp_server import create_mcp_server +from fastapi.testclient import TestClient +from fastmcp.exceptions import NotFoundError + + +@pytest.fixture +def vnc_backend(monkeypatch): + monkeypatch.setenv("CUA_BACKEND", "vnc") + monkeypatch.setenv("CUA_VNC_HOST", "127.0.0.1") + monkeypatch.setenv("PYNPUT_BACKEND", "dummy") + + +@pytest.mark.asyncio +async def test_vnc_factory_never_constructs_host_file_desktop_or_window_handlers( + vnc_backend, tmp_path +): + from computer_server.handlers.factory import HandlerFactory + + handlers = HandlerFactory.create_handlers() + + assert all(isinstance(handler, VNCUnavailableHandler) for handler in handlers[3:]) + + marker = tmp_path / "must-not-exist" + file_result = await handlers[3].write_text(str(marker), "host mutation") + shell_result = await handlers[1].run_command(f"touch {marker}") + + assert file_result["code"] == VNC_UNSUPPORTED_CODE + assert shell_result["code"] == VNC_UNSUPPORTED_CODE + assert not marker.exists() + + +@pytest.mark.asyncio +async def test_vnc_mcp_registry_contains_only_remote_target_tools(vnc_backend, tmp_path): + server = create_mcp_server() + names = {tool.name for tool in await server.list_tools()} + + assert names == VNC_REMOTE_MCP_TOOLS + + marker = tmp_path / "must-not-exist" + with pytest.raises(NotFoundError, match="computer_file_write"): + await server.call_tool( + "computer_file_write", + {"path": str(marker), "content": "host mutation"}, + ) + assert not marker.exists() + + +@pytest.mark.asyncio +async def test_non_vnc_mcp_registry_keeps_existing_host_tools(monkeypatch): + monkeypatch.setenv("CUA_BACKEND", "native") + monkeypatch.delenv("CUA_VNC_HOST", raising=False) + + names = {tool.name for tool in await create_mcp_server().list_tools()} + + assert { + "computer_screenshot", + "computer_run_command", + "computer_file_write", + "computer_set_wallpaper", + "computer_close_window", + } <= names + + +def test_vnc_command_registry_is_an_allowlist(vnc_backend): + candidate = {name: object() for name in VNC_REMOTE_COMMANDS} + candidate["future_host_operation"] = object() + + exposed = exposed_command_registry(candidate) + + assert set(exposed) == VNC_REMOTE_COMMANDS + + +def test_non_vnc_command_registry_is_unchanged(monkeypatch): + monkeypatch.setenv("CUA_BACKEND", "native") + monkeypatch.delenv("CUA_VNC_HOST", raising=False) + candidate = {"run_command": object(), "screenshot": object()} + + assert exposed_command_registry(candidate) == candidate + + +def test_vnc_http_and_websocket_commands_refuse_before_host_mutation( + vnc_backend, monkeypatch, tmp_path +): + from computer_server import main + + marker = tmp_path / "must-not-exist" + + async def write_text(path: str, content: str): + marker.write_text(content) + return {"success": True} + + monkeypatch.setattr( + main, + "handlers", + exposed_command_registry( + { + "write_text": write_text, + "screenshot": AsyncMock(return_value={"success": True}), + } + ), + ) + monkeypatch.setattr(main, "COMMAND_ALIASES", {}) + + with TestClient(main.app) as client: + advertised = client.get("/commands").json() + response = client.post( + "/cmd", + json={"command": "write_text", "params": {"path": str(marker), "content": "x"}}, + ) + assert response.status_code == 400 + + with client.websocket_connect("/ws") as websocket: + websocket.send_json( + { + "command": "write_text", + "params": {"path": str(marker), "content": "x"}, + } + ) + result = websocket.receive_json() + + assert set(advertised["commands"]) == {"screenshot"} + assert advertised["aliases"] == {} + assert result["success"] is False + assert "Unknown command" in result["error"] + assert not marker.exists() + + +def test_vnc_host_scoped_http_surfaces_are_refused(vnc_backend, monkeypatch): + from computer_server import main + + create_pty = AsyncMock() + browser_command = AsyncMock() + monkeypatch.setattr(main.pty_manager, "create", create_pty) + monkeypatch.setattr(main.get_browser_manager(), "execute_command", browser_command) + + with TestClient(main.app) as client: + pty_response = client.post("/pty", json={"command": "echo wrong-host"}) + browser_response = client.post( + "/playwright_exec", + json={"command": "visit_url", "params": {"url": "https://example.com"}}, + ) + agent_response = client.post( + "/responses", + json={ + "model": "test-model", + "input": "inspect the remote target", + "env": {"CUA_BACKEND": "native", "CUA_VNC_HOST": ""}, + }, + ) + + assert pty_response.status_code == 409 + assert pty_response.json()["code"] == VNC_UNSUPPORTED_CODE + assert browser_response.status_code == 409 + assert browser_response.json()["code"] == VNC_UNSUPPORTED_CODE + assert agent_response.status_code == 409 + assert agent_response.json()["code"] == VNC_UNSUPPORTED_CODE + assert os.environ["CUA_BACKEND"] == "vnc" + assert os.environ["CUA_VNC_HOST"] == "127.0.0.1" + create_pty.assert_not_awaited() + browser_command.assert_not_awaited() + + +def test_vnc_pty_websocket_is_refused_before_subscription(vnc_backend, monkeypatch): + from computer_server import main + + subscribe = Mock() + monkeypatch.setattr(main.pty_manager, "subscribe", subscribe) + + with TestClient(main.app) as client: + with client.websocket_connect("/pty/123/ws") as websocket: + result = websocket.receive_json() + + assert result["code"] == VNC_UNSUPPORTED_CODE + subscribe.assert_not_called() + + +@pytest.mark.asyncio +async def test_vnc_direct_agent_interface_refuses_host_browser(vnc_backend): + from computer_server.main import DirectComputerInterface + + browser = AsyncMock() + interface = DirectComputerInterface(automation_handler=object(), browser_manager=browser) + + result = await interface.playwright_exec("visit_url", {"url": "https://example.com"}) + + assert result["code"] == VNC_UNSUPPORTED_CODE + browser.execute_command.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_non_vnc_direct_agent_interface_keeps_browser_behavior(monkeypatch): + from computer_server.main import DirectComputerInterface + + monkeypatch.setenv("CUA_BACKEND", "native") + monkeypatch.delenv("CUA_VNC_HOST", raising=False) + browser = AsyncMock() + browser.execute_command.return_value = {"success": True} + interface = DirectComputerInterface(automation_handler=object(), browser_manager=browser) + + result = await interface.playwright_exec("visit_url", {"url": "https://example.com"}) + + assert result == {"success": True} + browser.execute_command.assert_awaited_once() From 22f93711a63d20ab11f4033a53e7dc02f706c676 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Wed, 19 Aug 2026 11:18:32 -0700 Subject: [PATCH 074/117] docs(sandbox): split pool/claim TTL into its own how-to guide (#3260) Move the 'Expire pools and claims automatically' section out of create-pool-with-python into a standalone guide at how-to-guides/sandbox/expire-pools-and-claims, leave a pointer in the pool guide, and add the page to the sandbox nav. --- .../sandbox/create-pool-with-python.mdx | 51 +---------- .../sandbox/expire-pools-and-claims.mdx | 84 +++++++++++++++++++ .../docs/how-to-guides/sandbox/meta.json | 1 + 3 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 docs/content/docs/how-to-guides/sandbox/expire-pools-and-claims.mdx diff --git a/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx b/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx index 75e7f746b1..bc52d52d3f 100644 --- a/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx +++ b/docs/content/docs/how-to-guides/sandbox/create-pool-with-python.mdx @@ -143,54 +143,9 @@ first claim after an idle period cold-starts a sandbox. ## Expire pools and claims automatically -Pools and claims accept an optional creation-age TTL. Pass -`ttl_seconds_after_created=` (requires `cua-sandbox>=0.4.2`) and the resource -is deleted once it has existed that many seconds, whether or not it is in use: - -```python -# Delete the whole pool 24 hours after it was first created. -pool = await Pool.apply( - Image.from_registry(IMAGE), - name=POOL_NAME, - replicas=1, - ttl_seconds_after_created=86400, -) - -# Delete this claim and its sandbox one hour after the claim was created. -async with pool.claim( - name=CLAIM_NAME, - ttl_seconds_after_created=3600, -) as sandbox: - ... -``` - -The clock starts at the resource's original creation, not its last use: -re-running `Pool.apply()` reconciles the existing pool without resetting its -age, and reconnecting to a named claim keeps the deadline set when the claim -was first created. Without the argument nothing is reaped — pools and claims -live until you delete them. - -A claim TTL fills in the claim's lifecycle shutdown time with a `Delete` -policy; a claim that already carries an explicit lifecycle keeps it. When you -build a claim spec by hand, put the TTL inside it — passing both -`spec=` and `ttl_seconds_after_created=` raises `ValueError`: - -```python -spec = ClaimSpec( - sandbox_template_ref=pool.resource.spec.sandbox_template_ref, - warmpool=None, - bind_deadline=None, - lifecycle=None, - ttl_seconds_after_created=3600, -) -async with pool.claim(spec=spec) as sandbox: - ... -``` - -Treat a pool TTL that can expire while claims are held as a capacity event, -not just cleanup: deleting the pool drains its sandboxes, and a claim that -outlives its pool destroys its sandbox on release instead of returning it to -the pool. +Pools and claims accept an optional creation-age TTL that deletes them a +fixed time after creation. See +[Expire pools and claims automatically](/how-to-guides/sandbox/expire-pools-and-claims). ## Choose pool and claim names diff --git a/docs/content/docs/how-to-guides/sandbox/expire-pools-and-claims.mdx b/docs/content/docs/how-to-guides/sandbox/expire-pools-and-claims.mdx new file mode 100644 index 0000000000..d79d02ae57 --- /dev/null +++ b/docs/content/docs/how-to-guides/sandbox/expire-pools-and-claims.mdx @@ -0,0 +1,84 @@ +--- +title: Expire pools and claims automatically +description: Set a creation-age TTL so sandbox pools and claims delete themselves. +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +Pools and claims accept an optional creation-age TTL. Pass +`ttl_seconds_after_created=` (requires `cua-sandbox>=0.4.2`) and the resource +is deleted once it has existed that many seconds, whether or not it is in use. +Without the argument nothing is reaped — pools and claims live until you +delete them. + +This guide assumes a pool created as in +[Create a sandbox pool with Python](/how-to-guides/sandbox/create-pool-with-python). + +## Expire a pool + +Pass the TTL to `Pool.apply()` to delete the whole pool — its warm replicas +and its template — a fixed time after the pool was first created: + +```python +from cua_sandbox import Image, Pool + +# Delete the whole pool 24 hours after it was first created. +pool = await Pool.apply( + Image.from_registry(IMAGE), + name=POOL_NAME, + replicas=1, + ttl_seconds_after_created=86400, +) +``` + +## Expire a claim + +Pass the same argument to `pool.claim()` (or `pool.create_claim()`) to delete +one claim and its sandbox a fixed time after the claim was created: + +```python +# Delete this claim and its sandbox one hour after the claim was created. +async with pool.claim( + name=CLAIM_NAME, + ttl_seconds_after_created=3600, +) as sandbox: + ... +``` + +A claim TTL fills in the claim's lifecycle shutdown time with a `Delete` +policy; a claim that already carries an explicit lifecycle keeps it. + +## The clock starts at creation + +The TTL counts from the resource's original creation, not its last use: +re-running `Pool.apply()` reconciles the existing pool without resetting its +age, and reconnecting to a named claim keeps the deadline set when the claim +was first created. + +## Hand-built claim specs + +When you build a claim spec by hand, put the TTL inside it — passing both +`spec=` and `ttl_seconds_after_created=` raises `ValueError`: + +```python +from cua_sandbox import ClaimSpec + +spec = ClaimSpec( + sandbox_template_ref=pool.resource.spec.sandbox_template_ref, + warmpool=None, + bind_deadline=None, + lifecycle=None, + ttl_seconds_after_created=3600, +) +async with pool.claim(spec=spec) as sandbox: + ... +``` + +## Pool expiry under live claims + + + Treat a pool TTL that can expire while claims are held as a capacity event, + not just cleanup: deleting the pool drains its sandboxes, and a claim that + outlives its pool destroys its sandbox on release instead of returning it + to the pool. + diff --git a/docs/content/docs/how-to-guides/sandbox/meta.json b/docs/content/docs/how-to-guides/sandbox/meta.json index 3986ad1cf1..2d28abfb7d 100644 --- a/docs/content/docs/how-to-guides/sandbox/meta.json +++ b/docs/content/docs/how-to-guides/sandbox/meta.json @@ -4,6 +4,7 @@ "lifecycle", "configure-pool-with-terraform", "create-pool-with-python", + "expire-pools-and-claims", "secrets", "scale-out", "tunneling", From f60085ab1017ce97c3a705b6e53266f7ffd09c63 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 19 Aug 2026 11:27:35 -0700 Subject: [PATCH 075/117] test: synchronize release wiring versions (#3261) --- .../scripts/tests/test_cua_fleet_release_wiring.py | 12 ++++++------ libs/python/cua-sandbox/.bumpversion.cfg | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/scripts/tests/test_cua_fleet_release_wiring.py b/.github/scripts/tests/test_cua_fleet_release_wiring.py index 1add9bf2ab..3b4dcafd13 100644 --- a/.github/scripts/tests/test_cua_fleet_release_wiring.py +++ b/.github/scripts/tests/test_cua_fleet_release_wiring.py @@ -9,14 +9,14 @@ class TestCuaFleetReleaseWiring(unittest.TestCase): """Keep Fleet's promotion workflow aligned with canonical SDK wheels.""" - def test_publisher_promotes_cua_fleet_0_1_12(self) -> None: + def test_publisher_promotes_cua_fleet_0_1_14(self) -> None: workflow = (REPO_ROOT / ".github/workflows/cd-py-fleet.yml").read_text() expected_sources = { - "cua_fleet-0.1.12-py3-none-manylinux_2_34_x86_64.whl": "d8ef6a0c8ac6e6f8dda937e3aebeef7f5b4fa9e3f29edc55a602a1c874f3f96a", - "cua_fleet-0.1.12-py3-none-manylinux_2_34_aarch64.whl": "0a39e52cbec94a2bc71f45282dd34c896fd154bc7f7a137fc6ceff82edfc0973", - "cua_fleet-0.1.12-py3-none-macosx_10_12_x86_64.whl": "d5114ba97ef208e257bef261f6d3585b265f1f2c32cb627bcc9f44d753e60b75", - "cua_fleet-0.1.12-py3-none-macosx_11_0_arm64.whl": "82762fae4943b20aae05b39362f5bd0c179f84c16dac1e948debb3d58db5adc2", - "cua_fleet-0.1.12-py3-none-win_amd64.whl": "c280d7ceadedc1d5c4c59869940c3390aac321f12ac9c9ee52250f212b6aac75", + "cua_fleet-0.1.14-py3-none-manylinux_2_34_x86_64.whl": "2dc70e98b3e8c691bf0fff0494b0a85d2405e872f1ca99dbfa2680473cea565b", + "cua_fleet-0.1.14-py3-none-manylinux_2_34_aarch64.whl": "d6ea25902cf9ffc89779530b6ae7cff5413383ea7dc3c632a4fb47e00699a6d4", + "cua_fleet-0.1.14-py3-none-macosx_10_12_x86_64.whl": "d370f83773574da8edcca080e9d86aec8eb7ed758d6c551b900482a8575f6810", + "cua_fleet-0.1.14-py3-none-macosx_11_0_arm64.whl": "e19784c0ce8aa2d9a77bf096fc6ff10e990842f05c67bdfb96a16ecd5e7123e2", + "cua_fleet-0.1.14-py3-none-win_amd64.whl": "3e73327b7c99dc95a4b4194628d3575a8707cab77d6929ed1bf34a82192a4464", } self.assertIn("https://wheels.cua.ai/simple/cua-fleet/$WHEEL", workflow) diff --git a/libs/python/cua-sandbox/.bumpversion.cfg b/libs/python/cua-sandbox/.bumpversion.cfg index b9f99a3bd1..99612cfd8b 100644 --- a/libs/python/cua-sandbox/.bumpversion.cfg +++ b/libs/python/cua-sandbox/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.4.1 +current_version = 0.4.2 commit = True tag = True tag_name = sandbox-v{new_version} From 4864ccd42673eaa535d5e5ad01f2e30b484a0b02 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 19 Aug 2026 12:40:56 -0700 Subject: [PATCH 076/117] fix(cua-driver): admit stable local history signatures (#3262) --- .../crates/cua-driver/src/history_runtime.rs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs index d18c9cf3a0..0d72ffc1af 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/history_runtime.rs @@ -267,7 +267,7 @@ pub fn verify_installed_app_for_history() -> anyhow::Result<()> { .lines() .find_map(|line| line.trim().strip_prefix("TeamIdentifier=")) .filter(|value| !value.is_empty() && *value != "not set") - .ok_or_else(|| anyhow::anyhow!("installed Cua Driver signature has no team identifier"))?; + .unwrap_or(""); let entitlements = Command::new("/usr/bin/codesign") .args(["-d", "--entitlements", "-", "--xml", helper.as_ref()]) .output()?; @@ -306,12 +306,16 @@ fn validate_history_app_signature( if !requirement.contains("certificate leaf") { anyhow::bail!("installed Cua Driver signature is not certificate-backed"); } - if team_identifier.is_empty() || team_identifier == "not set" { - anyhow::bail!("installed Cua Driver signature has no team identifier"); - } + // A stable self-signed local-development certificate has a certificate-leaf + // designated requirement but no Apple TeamIdentifier. That leaf pins local + // history to the same signing identity across rebuilds. Production still + // requires the exact Apple team and device-protected Keychain entitlements. if !require_release_entitlements { return Ok(()); } + if team_identifier.is_empty() || team_identifier == "not set" { + anyhow::bail!("installed Cua Driver signature has no team identifier"); + } if team_identifier != RELEASE_TEAM_IDENTIFIER { anyhow::bail!("installed Cua Driver signature does not match the release signing team"); } @@ -554,11 +558,11 @@ mod tests { #[test] #[cfg(target_os = "macos")] - fn local_history_accepts_exact_certificate_identity_without_release_entitlements() { + fn local_history_accepts_certificate_identity_without_apple_team_identifier() { validate_history_app_signature( - "Identifier=com.trycua.driver.local\nTeamIdentifier=TEAM123", - "designated => identifier \"com.trycua.driver.local\" and certificate leaf[subject.OU] = TEAM123", - "TEAM123", + "Identifier=com.trycua.driver.local\nTeamIdentifier=not set", + "designated => identifier \"com.trycua.driver.local\" and certificate leaf = H\"d2badc24c61056ede3b61724c54c5a7d1649ce4d\"", + "", "", "com.trycua.driver.local", false, @@ -592,6 +596,15 @@ mod tests { #[test] #[cfg(target_os = "macos")] fn release_history_still_requires_device_protected_keychain_entitlements() { + assert!(validate_history_app_signature( + "Identifier=com.trycua.driver\nTeamIdentifier=not set", + "designated => anchor apple generic and identifier \"com.trycua.driver\" and certificate leaf = H\"1234\"", + "", + "", + "com.trycua.driver", + true, + ) + .is_err()); let detail = format!("Identifier=com.trycua.driver\nTeamIdentifier={RELEASE_TEAM_IDENTIFIER}"); let requirement = format!( From 6aa6438abfb30cdfd2d6e8d94cb2480916bc3966 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 19 Aug 2026 13:35:17 -0700 Subject: [PATCH 077/117] ci(cua-driver): bound Ubuntu package downloads (#3268) * ci(cua-driver): bound Ubuntu package downloads * ci(cua-driver): avoid stalled hosted mirror --- .../ci-cua-driver-contract-clients.yml | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-cua-driver-contract-clients.yml b/.github/workflows/ci-cua-driver-contract-clients.yml index e68649be98..91488add9f 100644 --- a/.github/workflows/ci-cua-driver-contract-clients.yml +++ b/.github/workflows/ci-cua-driver-contract-clients.yml @@ -81,8 +81,11 @@ jobs: cache-dependency-path: .github/scripts/cua-driver-mcp-compat/package-lock.json - name: Install Linux build dependencies run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ + if [[ -f /etc/apt/apt-mirrors.txt ]]; then + sudo sed -i 's#http://azure.archive.ubuntu.com/ubuntu#https://archive.ubuntu.com/ubuntu#g' /etc/apt/apt-mirrors.txt + fi + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 install -y --no-install-recommends \ clang pkg-config libdbus-1-dev libpipewire-0.3-dev libspa-0.2-dev \ libei-dev libxkbcommon-dev libx11-dev libxi-dev libxtst-dev libxext-dev - name: Build the release-mode MCP server @@ -124,8 +127,11 @@ jobs: - name: Install Linux build dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ + if [[ -f /etc/apt/apt-mirrors.txt ]]; then + sudo sed -i 's#http://azure.archive.ubuntu.com/ubuntu#https://archive.ubuntu.com/ubuntu#g' /etc/apt/apt-mirrors.txt + fi + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 install -y --no-install-recommends \ clang pkg-config libdbus-1-dev libpipewire-0.3-dev libspa-0.2-dev \ libei-dev libxkbcommon-dev libx11-dev libxi-dev libxtst-dev libxext-dev - name: Prove portable contracts match the live registry @@ -182,8 +188,11 @@ jobs: workspaces: "libs/cua-driver/rust -> target" - name: Install Linux build dependencies run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ + if [[ -f /etc/apt/apt-mirrors.txt ]]; then + sudo sed -i 's#http://azure.archive.ubuntu.com/ubuntu#https://archive.ubuntu.com/ubuntu#g' /etc/apt/apt-mirrors.txt + fi + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 update + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 -o Acquire::https::Timeout=15 install -y --no-install-recommends \ clang pkg-config libdbus-1-dev libpipewire-0.3-dev libspa-0.2-dev \ libei-dev libxkbcommon-dev libx11-dev libxi-dev libxtst-dev libxext-dev - uses: actions/setup-python@v5 From 70db98d1bcd92890d778f4978e0eb107a4b66c1b Mon Sep 17 00:00:00 2001 From: "cua-release-bot[bot]" <254316371+cua-release-bot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:59:34 -0700 Subject: [PATCH 078/117] chore(main): release cua-driver-rs 0.21.0 (#3192) * chore(main): release cua-driver-rs 0.21.0 * chore(cua-driver-rs): synchronize generated release files --------- Co-authored-by: cua-release-bot[bot] <254316371+cua-release-bot[bot]@users.noreply.github.com> Co-authored-by: trycua-release[bot] --- .release-please-manifest.json | 2 +- .../reference/cua-driver/cli-reference.mdx | 4 +-- .../docs/reference/cua-driver/mcp-tools.mdx | 2 +- libs/cua-driver/python/pyproject.toml | 2 +- .../python/src/cua_driver/__init__.py | 2 +- libs/cua-driver/rust/CHANGELOG.md | 22 ++++++++++++++++ libs/cua-driver/rust/Cargo.lock | 26 +++++++++---------- libs/cua-driver/rust/Cargo.toml | 2 +- .../rust/Skills/cua-driver/SKILL.md | 2 +- libs/cua-driver/rust/VERSION | 2 +- libs/cua-driver/typescript/package-lock.json | 4 +-- libs/cua-driver/typescript/package.json | 2 +- 12 files changed, 47 insertions(+), 25 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 026a19dbdb..7f9339269c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - "libs/cua-driver": "0.20.0", + "libs/cua-driver": "0.21.0", "libs/lume": "0.5.3" } diff --git a/docs/content/docs/reference/cua-driver/cli-reference.mdx b/docs/content/docs/reference/cua-driver/cli-reference.mdx index 580da6fa12..3d1dcc1977 100644 --- a/docs/content/docs/reference/cua-driver/cli-reference.mdx +++ b/docs/content/docs/reference/cua-driver/cli-reference.mdx @@ -7,7 +7,7 @@ description: Command-line interface specification for Cua Driver AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: cua-driver dump-docs - Version: 0.20.0 + Version: 0.21.0 */} Cross-platform computer-use automation driver. Install via the official script: @@ -16,7 +16,7 @@ Cross-platform computer-use automation driver. Install via the official script: curl -fsSL https://cua.ai/driver/install.sh | bash ``` -Documented against Cua Driver **0.20.0**. Run `cua-driver --version` for your installed version. +Documented against Cua Driver **0.21.0**. Run `cua-driver --version` for your installed version. The macOS-only `cua-driver permissions` command is documented separately in [macOS permissions](/reference/cua-driver/macos-permissions). diff --git a/docs/content/docs/reference/cua-driver/mcp-tools.mdx b/docs/content/docs/reference/cua-driver/mcp-tools.mdx index ff2573b5a8..1a2925c686 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools.mdx @@ -7,7 +7,7 @@ description: Reference for every MCP tool Cua Driver exposes AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: cua-driver dump-docs - Version: 0.20.0 + Version: 0.21.0 */} import { Callout } from 'fumadocs-ui/components/callout'; diff --git a/libs/cua-driver/python/pyproject.toml b/libs/cua-driver/python/pyproject.toml index 4f75245303..2b478239ab 100644 --- a/libs/cua-driver/python/pyproject.toml +++ b/libs/cua-driver/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cua-driver" -version = "0.20.0" +version = "0.21.0" description = "Rust-backed Cua Driver SDK and bundled executable for client applications" readme = "README.md" license = { text = "MIT" } diff --git a/libs/cua-driver/python/src/cua_driver/__init__.py b/libs/cua-driver/python/src/cua_driver/__init__.py index bafc7b46ef..aaab22c944 100644 --- a/libs/cua-driver/python/src/cua_driver/__init__.py +++ b/libs/cua-driver/python/src/cua_driver/__init__.py @@ -4,7 +4,7 @@ through their runtime's MCP client instead of importing a language MCP facade. """ -__version__ = "0.20.0" # x-release-please-version +__version__ = "0.21.0" # x-release-please-version from ._native import ( ActionCompletion, diff --git a/libs/cua-driver/rust/CHANGELOG.md b/libs/cua-driver/rust/CHANGELOG.md index d44d287670..d4de2fbf28 100644 --- a/libs/cua-driver/rust/CHANGELOG.md +++ b/libs/cua-driver/rust/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [0.21.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.20.0...cua-driver-rs-v0.21.0) (2026-08-19) + + +### Features + +* **cua-driver:** extend Project Centennial across desktop platforms ([#3189](https://github.com/trycua/cua/issues/3189)) ([a744308](https://github.com/trycua/cua/commit/a74430843663d3840dffbee04c2e112783ad238e)) +* **cua-driver:** Project Centennial preview ([#3188](https://github.com/trycua/cua/issues/3188)) ([61e51cd](https://github.com/trycua/cua/commit/61e51cddc48edcd0ae350a1744890c04951027f1)) + + +### Bug Fixes + +* **cua-driver:** admit stable local history signatures ([#3262](https://github.com/trycua/cua/issues/3262)) ([4864ccd](https://github.com/trycua/cua/commit/4864ccd42673eaa535d5e5ad01f2e30b484a0b02)) +* **cua-driver:** attribute hosted Windows apps by window ([#3227](https://github.com/trycua/cua/issues/3227)) ([30d9130](https://github.com/trycua/cua/commit/30d9130f7c747d9d3ecb2a5eca5b53366db5f1bb)) +* **cua-driver:** drop the stale 0.17 version from element_index messages ([e14891a](https://github.com/trycua/cua/commit/e14891ae957d710e4e5cbdc1e08e8bffd355cd88)) +* **cua-driver:** harden consented existing-profile attachment ([#3211](https://github.com/trycua/cua/issues/3211)) ([3f791b2](https://github.com/trycua/cua/commit/3f791b2cfec23d690cd34e6d275b6cbe1a8acc05)) +* **cua-driver:** launch isolated browser without pid ([#3208](https://github.com/trycua/cua/issues/3208)) ([eaf5a34](https://github.com/trycua/cua/commit/eaf5a34ea7804f8837bf39f7453e4beb5e6dce39)) +* **cua-driver:** preserve history admission on macOS relaunch ([#3245](https://github.com/trycua/cua/issues/3245)) ([9045b0c](https://github.com/trycua/cua/commit/9045b0c74f7c7de72fde3d9dc622f2cacf1cf848)) +* **cua-driver:** refuse unproven Wayland window capture ([#3200](https://github.com/trycua/cua/issues/3200)) ([c78c1d8](https://github.com/trycua/cua/commit/c78c1d873b5c091a951716559d2ccc1dc2f7a0e6)) +* **cua-driver:** strip UTF-8 BOM from uninstall.ps1 ([da44420](https://github.com/trycua/cua/commit/da44420193b59e845887db1f6c4adf27fbb2ffb8)), closes [#3174](https://github.com/trycua/cua/issues/3174) +* **cua-driver:** tell operators to reconnect agent sessions after history enable ([#3224](https://github.com/trycua/cua/issues/3224)) ([62ede45](https://github.com/trycua/cua/commit/62ede45ad8450e341e9521bb8a8f81c9705f1c12)), closes [#3220](https://github.com/trycua/cua/issues/3220) +* **cua-driver:** tolerate missing local autostart task ([#3229](https://github.com/trycua/cua/issues/3229)) ([f913244](https://github.com/trycua/cua/commit/f9132440013edaf99937682ea3ad44326ae16df2)) + ## [0.20.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.19.3...cua-driver-rs-v0.20.0) (2026-08-15) diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index e6a27645d6..fe2d59d49d 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "async-trait", @@ -1176,7 +1176,7 @@ dependencies = [ [[package]] name = "cua-driver-bindgen" -version = "0.20.0" +version = "0.21.0" dependencies = [ "cbindgen", "uniffi", @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "cua-driver-contract" -version = "0.20.0" +version = "0.21.0" dependencies = [ "schemars", "serde", @@ -1194,7 +1194,7 @@ dependencies = [ [[package]] name = "cua-driver-core" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "async-trait", @@ -1232,7 +1232,7 @@ dependencies = [ [[package]] name = "cua-driver-sdk" -version = "0.20.0" +version = "0.21.0" dependencies = [ "async-trait", "core-foundation", @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "cua-driver-testkit" -version = "0.20.0" +version = "0.21.0" dependencies = [ "core-foundation", "libc", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "cua-driver-uia" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "cua-driver-core", @@ -1283,7 +1283,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "cua-driver-contract", @@ -1300,7 +1300,7 @@ dependencies = [ [[package]] name = "cursor-theme-cli" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "cursor-overlay", @@ -3146,7 +3146,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pip-preview" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "serde_json", @@ -3206,7 +3206,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-linux" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "ashpd", @@ -3256,7 +3256,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "async-trait", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.20.0" +version = "0.21.0" dependencies = [ "anyhow", "async-trait", diff --git a/libs/cua-driver/rust/Cargo.toml b/libs/cua-driver/rust/Cargo.toml index 847b8d831e..df90cdc80d 100644 --- a/libs/cua-driver/rust/Cargo.toml +++ b/libs/cua-driver/rust/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "0.20.0" +version = "0.21.0" edition = "2021" authors = ["trycua"] license = "MIT" diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 308d1040f4..4747855d66 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -1,7 +1,7 @@ --- name: cua-driver description: Drive a native GUI app (macOS, Windows, Linux) via the cua-driver CLI (default) or MCP server; snapshot its accessibility tree, act through snapshot-bound element tokens, native menu paths, exact window geometry, or pixel coordinates, and verify from fresh state. Use when the user asks you to operate, drive, automate, or perform a GUI task in a real application on the host, or to continue, resume, or recall recent Cua activity. -version: 0.20.0 # x-release-please-version +version: 0.21.0 # x-release-please-version metadata: openclaw: requires: diff --git a/libs/cua-driver/rust/VERSION b/libs/cua-driver/rust/VERSION index 5a03fb737b..885415662f 100644 --- a/libs/cua-driver/rust/VERSION +++ b/libs/cua-driver/rust/VERSION @@ -1 +1 @@ -0.20.0 +0.21.0 diff --git a/libs/cua-driver/typescript/package-lock.json b/libs/cua-driver/typescript/package-lock.json index 4e888f98dd..65dc5b27ee 100644 --- a/libs/cua-driver/typescript/package-lock.json +++ b/libs/cua-driver/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trycua/cua-driver", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trycua/cua-driver", - "version": "0.20.0", + "version": "0.21.0", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cua-driver/typescript/package.json b/libs/cua-driver/typescript/package.json index 0c360663e5..eef155d4f1 100644 --- a/libs/cua-driver/typescript/package.json +++ b/libs/cua-driver/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@trycua/cua-driver", - "version": "0.20.0", + "version": "0.21.0", "description": "Rust-backed Cua Driver SDK and embedded Node host", "type": "module", "license": "MIT", From 291ccdd8305597cf4023b3d800073996742e5d05 Mon Sep 17 00:00:00 2001 From: "trycua-release[bot]" Date: Wed, 19 Aug 2026 21:37:25 +0000 Subject: [PATCH 079/117] chore(cua-driver): advance published installer version to 0.21.0 [skip ci] --- .github/release-state/cua-driver-rs-published-version | 2 +- libs/cua-driver/scripts/_install-rust.sh | 2 +- libs/cua-driver/scripts/install.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/release-state/cua-driver-rs-published-version b/.github/release-state/cua-driver-rs-published-version index 5a03fb737b..885415662f 100644 --- a/.github/release-state/cua-driver-rs-published-version +++ b/.github/release-state/cua-driver-rs-published-version @@ -1 +1 @@ -0.20.0 +0.21.0 diff --git a/libs/cua-driver/scripts/_install-rust.sh b/libs/cua-driver/scripts/_install-rust.sh index 522632c22c..6a1183d04f 100644 --- a/libs/cua-driver/scripts/_install-rust.sh +++ b/libs/cua-driver/scripts/_install-rust.sh @@ -523,7 +523,7 @@ done # asset — see the recovery at the download step below. # # ~~~ BAKED_VERSION: auto-updated after release publication — do not edit ~~~ -CUA_DRIVER_RS_BAKED_VERSION="0.20.0" # published-installer-version +CUA_DRIVER_RS_BAKED_VERSION="0.21.0" # published-installer-version # ~~~ END_BAKED_VERSION ~~~ # Run API requests with an optional token. Keep the header construction here diff --git a/libs/cua-driver/scripts/install.ps1 b/libs/cua-driver/scripts/install.ps1 index 094aafa1b1..a94c0dba70 100644 --- a/libs/cua-driver/scripts/install.ps1 +++ b/libs/cua-driver/scripts/install.ps1 @@ -123,7 +123,7 @@ $ThemeBinaryName = "cua-cursor-theme.exe" # where the baked line hasn't been updated yet. # # ~~~ BAKED_VERSION: auto-updated after release publication — do not edit ~~~ -$Script:CuaDriverRsBakedVersion = "0.20.0" # published-installer-version +$Script:CuaDriverRsBakedVersion = "0.21.0" # published-installer-version # ~~~ END_BAKED_VERSION ~~~ $CursorThemeRequiredFrom = [version]"0.12.7" From 9bf47f65dda1bc33bdd19dabb94c56b691f39ca0 Mon Sep 17 00:00:00 2001 From: Hal <13111745+loonghao@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:37:27 +0800 Subject: [PATCH 080/117] fix(cua-driver): make Windows browser prepare language independent (#3135) * fix(cua-driver): support localized Windows browser chrome * refactor(cua-driver): make browser setup language independent * fix(cua-driver): match localized Chromium consent structurally * fix(cua-driver): stabilize native browser tab proof * fix(cua-driver): remove setup page language assumptions * test(browser): cover more scripts and missing labels * fix(cua-driver): bind consent actions to native prompt * fix(cua-driver): reuse unique pre-enabled browser endpoint * fix(cua-driver): foreground browser before setup tab * fix(cua-driver): invoke native browser new-tab control * fix(cua-driver): disambiguate Edge address field * test(cua-driver): diagnose Edge consent structure * fix(cua-driver): support Edge consent topology --------- Co-authored-by: Francesco Bonacci --- .../src/browser_consent_ui.rs | 614 ++++++++++++++++-- .../platform-windows/src/browser_platform.rs | 84 ++- .../platform-windows/src/browser_setup_ui.rs | 603 +++++++++++++---- 3 files changed, 1092 insertions(+), 209 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs index a12808e80f..80163868fe 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_consent_ui.rs @@ -14,23 +14,17 @@ use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; use crate::uia::UiaNode; -fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefusal { - BrowserRefusal::new(code, message) +const CHROMIUM_DIALOG_BUTTON_CLASS: &str = "MdTextButton"; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ConsentButtonCandidate { + element_ptr: usize, + rect: (i32, i32, i32, i32), + has_keyboard_focus: bool, } -fn normalized_text(node: &UiaNode) -> String { - [ - node.name.as_deref(), - node.value.as_deref(), - node.automation_id.as_deref(), - node.help_text.as_deref(), - ] - .into_iter() - .flatten() - .collect::>() - .join(" ") - .trim() - .to_ascii_lowercase() +fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefusal { + BrowserRefusal::new(code, message) } fn release_nodes(nodes: &[UiaNode]) { @@ -59,50 +53,290 @@ fn is_in_web_content(nodes: &[UiaNode], node: &UiaNode) -> bool { true } -fn trusted_prompt_nodes(nodes: &[UiaNode]) -> impl Iterator { - nodes.iter().filter(|node| { - !node.in_web_content - && !node.control_type.eq_ignore_ascii_case("Document") - && !is_in_web_content(nodes, node) +fn is_trusted_prompt_node(nodes: &[UiaNode], node: &UiaNode) -> bool { + !node.in_web_content + && !node.control_type.eq_ignore_ascii_case("Document") + && !is_in_web_content(nodes, node) +} + +fn node_name(node: &UiaNode) -> Option<&str> { + node.name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) +} + +fn subtree_end(nodes: &[UiaNode], root_index: usize) -> usize { + let root_depth = nodes[root_index].depth; + nodes + .iter() + .enumerate() + .skip(root_index + 1) + .find(|(_, node)| node.depth <= root_depth) + .map_or(nodes.len(), |(index, _)| index) +} + +fn has_matching_pane_ancestor(nodes: &[UiaNode], window_index: usize, name: &str) -> bool { + let mut descendant_depth = nodes[window_index].depth; + for ancestor in nodes[..window_index].iter().rev() { + if ancestor.depth >= descendant_depth { + continue; + } + descendant_depth = ancestor.depth; + if is_trusted_prompt_node(nodes, ancestor) + && ancestor.control_type == "Pane" + && node_name(ancestor) == Some(name) + { + return true; + } + } + false +} + +fn has_pane_rooted_title_binding(nodes: &[UiaNode], pane_index: usize, name: &str) -> bool { + let pane = &nodes[pane_index]; + let end = subtree_end(nodes, pane_index); + let descendants = &nodes[(pane_index + 1)..end]; + let has_direct_title = descendants.iter().any(|node| { + is_trusted_prompt_node(nodes, node) + && node.depth == pane.depth + 1 + && node.control_type == "Text" + && node_name(node) == Some(name) + }); + let has_nested_title = descendants.iter().any(|node| { + is_trusted_prompt_node(nodes, node) + && node.depth > pane.depth + 1 + && node.control_type == "Text" + && node_name(node) == Some(name) + }); + let has_nested_window = descendants + .iter() + .any(|node| is_trusted_prompt_node(nodes, node) && node.control_type == "Window"); + has_direct_title && has_nested_title && !has_nested_window +} + +fn native_prompt_surfaces(nodes: &[UiaNode]) -> Vec<(usize, usize)> { + nodes + .iter() + .enumerate() + .filter_map(|(root_index, root)| { + if !is_trusted_prompt_node(nodes, root) || root.depth == 0 { + return None; + } + let name = node_name(root)?; + let end = subtree_end(nodes, root_index); + match root.control_type.as_str() { + "Window" => { + if !has_matching_pane_ancestor(nodes, root_index, name) { + return None; + } + nodes[(root_index + 1)..end] + .iter() + .any(|node| { + is_trusted_prompt_node(nodes, node) + && node.control_type == "Text" + && node_name(node) == Some(name) + }) + .then_some((root_index, end)) + } + "Pane" if root.depth > 1 => has_pane_rooted_title_binding(nodes, root_index, name) + .then_some((root_index, end)), + _ => None, + } + }) + .collect() +} + +fn native_prompt_surface_present(nodes: &[UiaNode]) -> bool { + !native_prompt_surfaces(nodes).is_empty() +} + +fn native_button_properties(element_ptr: usize) -> Result<(String, bool), BrowserRefusal> { + let element = unsafe { IUIAutomationElement::from_raw(element_ptr as *mut _) }; + let properties = unsafe { + element.CurrentClassName().and_then(|class_name| { + element + .CurrentHasKeyboardFocus() + .map(|focused| (class_name.to_string(), focused.as_bool())) + }) + }; + std::mem::forget(element); + properties.map_err(|error| { + refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!("could not prove a native Chromium consent button: {error}"), + ) }) } -fn remote_debugging_prompt_present(nodes: &[UiaNode]) -> bool { - let has_title = - trusted_prompt_nodes(nodes).any(|node| normalized_text(node) == "allow remote debugging?"); - let body = trusted_prompt_nodes(nodes) - .map(normalized_text) - .collect::>() - .join(" "); - has_title - && body.contains("external app wants full control") - && body.contains("saved data, cookies and site data") - && body.contains("navigate to any url") +fn edge_gap(first: (i32, i32, i32, i32), second: (i32, i32, i32, i32)) -> Option { + let (first_left, first_top, first_right, first_bottom) = first; + let (second_left, second_top, second_right, second_bottom) = second; + let same_row = first_top == second_top && first_bottom == second_bottom; + if !same_row { + return None; + } + if first_right <= second_left { + Some(i64::from(second_left) - i64::from(first_right)) + } else if second_right <= first_left { + Some(i64::from(first_left) - i64::from(second_right)) + } else { + None + } } -fn exact_allow_button(nodes: &[UiaNode]) -> Result, BrowserRefusal> { - if !remote_debugging_prompt_present(nodes) { +fn select_language_independent_allow( + candidates: &[ConsentButtonCandidate], +) -> Result { + // Chromium builds this modal from one separated extra action plus the + // standard OK/Cancel pair. Accessible names are localized, and the whole + // footer mirrors for RTL locales, but adjacency and separation are stable. + // Cancel focus is additional contradiction evidence when the browser is + // active; an inactive browser legitimately reports no focused button. + if candidates.len() != 3 { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent prompt did not expose exactly three distinct dialog buttons", + )); + } + let focused_indices = candidates + .iter() + .enumerate() + .filter(|(_, candidate)| candidate.has_keyboard_focus) + .map(|(index, _)| index) + .collect::>(); + if focused_indices.len() > 1 { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent prompt exposed multiple focused dialog buttons", + )); + } + + let mut pairwise_gaps = Vec::new(); + for first in 0..candidates.len() { + for second in (first + 1)..candidates.len() { + if let Some(gap) = edge_gap(candidates[first].rect, candidates[second].rect) { + pairwise_gaps.push((gap, first, second)); + } + } + } + pairwise_gaps.sort_by_key(|(gap, _, _)| *gap); + let [(standard_gap, first_standard, second_standard), (extra_gap, _, _), _] = + pairwise_gaps.as_slice() + else { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent buttons did not form one exact non-overlapping dialog row", + )); + }; + let first_width = i64::from(candidates[*first_standard].rect.2) + - i64::from(candidates[*first_standard].rect.0); + let second_width = i64::from(candidates[*second_standard].rect.2) + - i64::from(candidates[*second_standard].rect.0); + if *standard_gap >= *extra_gap + || *standard_gap > first_width.max(second_width) + || *extra_gap < standard_gap.saturating_mul(2) + { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent prompt had no uniquely separated standard button pair", + )); + } + + let extra_index = (0..candidates.len()) + .find(|index| index != first_standard && index != second_standard) + .expect("three candidates and a two-button pair"); + let first_to_extra = edge_gap( + candidates[*first_standard].rect, + candidates[extra_index].rect, + ); + let second_to_extra = edge_gap( + candidates[*second_standard].rect, + candidates[extra_index].rect, + ); + let allow_index = match (first_to_extra, second_to_extra) { + (Some(first_gap), Some(second_gap)) if first_gap < second_gap => *first_standard, + (Some(first_gap), Some(second_gap)) if second_gap < first_gap => *second_standard, + _ => { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent prompt had no unique standard button adjacent to the extra action", + )); + } + }; + let cancel_index = if allow_index == *first_standard { + *second_standard + } else { + *first_standard + }; + if focused_indices + .first() + .is_some_and(|focused| *focused != cancel_index) + { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native Chromium consent prompt focus contradicted the structural cancel button", + )); + } + Ok(candidates[allow_index].element_ptr) +} + +fn exact_allow_button_with( + nodes: &[UiaNode], + mut properties: F, +) -> Result, BrowserRefusal> +where + F: FnMut(usize) -> Result<(String, bool), BrowserRefusal>, +{ + let surfaces = native_prompt_surfaces(nodes); + if surfaces.is_empty() { return Ok(None); } - let matches = trusted_prompt_nodes(nodes) - .filter(|node| { - node.control_type == "Button" - && normalized_text(node) == "allow" + let mut matches = Vec::new(); + for (start, end) in surfaces { + let mut candidates = Vec::new(); + for node in nodes[(start + 1)..end].iter().filter(|node| { + is_trusted_prompt_node(nodes, node) + && node.control_type == "Button" && node.actions.iter().any(|action| action == "invoke") && node.element_ptr != 0 - }) - .map(|node| node.element_ptr) - .collect::>(); + && node.rect.is_some() + }) { + let (class_name, has_keyboard_focus) = properties(node.element_ptr)?; + if class_name != CHROMIUM_DIALOG_BUTTON_CLASS { + continue; + } + let rect = node.rect.expect("filtered above"); + if rect.0 >= rect.2 || rect.1 >= rect.3 { + continue; + } + if candidates + .iter() + .any(|candidate: &ConsentButtonCandidate| candidate.rect == rect) + { + continue; + } + candidates.push(ConsentButtonCandidate { + element_ptr: node.element_ptr, + rect, + has_keyboard_focus, + }); + } + matches.push(select_language_independent_allow(&candidates)?); + } match matches.as_slice() { - [] => Ok(None), [element] => Ok(Some(*element)), _ => Err(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, - "multiple exact Allow actions matched the browser consent prompt", + "multiple bound native Chromium consent prompts exposed structural allow actions", )), } } +fn exact_allow_button(nodes: &[UiaNode]) -> Result, BrowserRefusal> { + exact_allow_button_with(nodes, native_button_properties) +} + unsafe fn invoke(element_ptr: usize) -> Result<(), BrowserRefusal> { let element = IUIAutomationElement::from_raw(element_ptr as *mut _); let result = element @@ -153,7 +387,7 @@ pub async fn handle( format!("could not inspect the browser consent UI: {error}"), ) })?; - let prompt_present = remote_debugging_prompt_present(&tree.nodes); + let prompt_present = native_prompt_surface_present(&tree.nodes); saw_prompt |= prompt_present; match exact_allow_button(&tree.nodes) { Ok(Some(element)) => { @@ -193,7 +427,7 @@ mod tests { fn node(control_type: &str, name: &str, actions: &[&str]) -> UiaNode { UiaNode { - element_index: (!actions.is_empty()).then_some(0), + element_index: None, control_type: control_type.to_owned(), name: Some(name.to_owned()), value: None, @@ -202,7 +436,7 @@ mod tests { actions: actions.iter().map(|value| (*value).to_owned()).collect(), enabled: None, selected: None, - element_ptr: 7, + element_ptr: 0, center_x: 0, center_y: 0, rect: None, @@ -213,47 +447,293 @@ mod tests { } } - fn prompt() -> Vec { + fn dialog_node(control_type: &str, name: &str, depth: usize) -> UiaNode { + let mut node = node(control_type, name, &[]); + node.depth = depth; + node + } + + fn button(name: &str, element_ptr: usize, rect: (i32, i32, i32, i32)) -> UiaNode { + let mut node = node("Button", name, &["invoke"]); + node.element_index = Some(element_ptr); + node.element_ptr = element_ptr; + node.rect = Some(rect); + node.depth = 8; + node + } + + fn prompt(title: &str, labels: [&str; 3]) -> Vec { vec![ - node("Text", "Allow remote debugging?", &[]), - node( - "Text", - "An external app wants full control. This includes access to your saved data, cookies and site data, and the ability to navigate to any URL.", - &[], - ), - node("Button", "Cancel", &["invoke"]), - node("Button", "Allow", &["invoke"]), + dialog_node("Pane", title, 2), + dialog_node("Window", title, 3), + dialog_node("Text", title, 7), + button(labels[0], 11, (-6066, 343, -5886, 399)), + button(labels[1], 12, (-5657, 343, -5560, 399)), + button(labels[2], 13, (-5549, 343, -5452, 399)), + ] + } + + fn pane_rooted_prompt(title: &str, labels: [&str; 3]) -> Vec { + vec![ + dialog_node("Pane", title, 2), + dialog_node("Text", title, 3), + dialog_node("Text", title, 7), + dialog_node("Text", "opaque explanatory surface", 10), + button(labels[0], 11, (282, 293, 419, 325)), + button(labels[1], 12, (574, 293, 629, 325)), + button(labels[2], 13, (637, 293, 698, 325)), ] } + fn properties(element_ptr: usize) -> Result<(String, bool), BrowserRefusal> { + Ok((CHROMIUM_DIALOG_BUTTON_CLASS.to_owned(), element_ptr == 13)) + } + #[test] - fn matcher_requires_exact_security_prompt_and_unique_allow_action() { - assert_eq!(exact_allow_button(&prompt()).unwrap(), Some(7)); + fn matcher_is_language_independent_across_localized_native_dialogs() { + for (title, labels) in [ + ( + "リモート デバッグを許可しますか?", + ["設定でオフ", "許可", "キャンセル"], + ), + ( + "Remote-Debugging zulassen?", + ["In Einstellungen deaktivieren", "Zulassen", "Abbrechen"], + ), + ( + "Autoriser le débogage à distance ?", + ["Désactiver", "Autoriser", "Annuler"], + ), + ( + "هل تريد السماح بتصحيح الأخطاء عن بُعد؟", + ["تعطيل", "سماح", "إلغاء"], + ), + ] { + assert_eq!( + exact_allow_button_with(&prompt(title, labels), properties).unwrap(), + Some(12) + ); + } + } + + #[test] + fn matcher_supports_pane_rooted_native_edge_prompt() { + assert_eq!( + exact_allow_button_with( + &pane_rooted_prompt( + "¿Permitir la depuración remota?", + ["Desactivar", "Permitir", "Cancelar"], + ), + properties, + ) + .unwrap(), + Some(12) + ); + } + + #[test] + fn pane_rooted_prompt_requires_both_title_bindings_and_no_nested_window() { + let mut missing_direct = pane_rooted_prompt("opaque title", ["A", "B", "C"]); + missing_direct[1].name = Some("different direct title".to_owned()); + assert_eq!( + exact_allow_button_with(&missing_direct, properties).unwrap(), + None + ); + + let mut missing_nested = pane_rooted_prompt("opaque title", ["A", "B", "C"]); + missing_nested[2].name = Some("different nested title".to_owned()); + assert_eq!( + exact_allow_button_with(&missing_nested, properties).unwrap(), + None + ); + + let mut nested_window = pane_rooted_prompt("opaque title", ["A", "B", "C"]); + nested_window.insert(2, dialog_node("Window", "unrelated native window", 3)); assert_eq!( - exact_allow_button(&[node("Button", "Allow", &["invoke"])]).unwrap(), + exact_allow_button_with(&nested_window, properties).unwrap(), + None + ); + + let mut browser_root = pane_rooted_prompt("opaque title", ["A", "B", "C"]); + browser_root[0].depth = 1; + assert_eq!( + exact_allow_button_with(&browser_root, properties).unwrap(), None ); } #[test] - fn matcher_refuses_ambiguous_allow_actions() { - let mut nodes = prompt(); - nodes.push(node("Button", "Allow", &["invoke"])); + fn matcher_refuses_multiple_pane_rooted_native_prompts() { + let mut nodes = pane_rooted_prompt("first opaque title", ["A", "B", "C"]); + nodes.extend(pane_rooted_prompt("second opaque title", ["D", "E", "F"])); + assert_eq!( - exact_allow_button(&nodes).unwrap_err().code, + exact_allow_button_with(&nodes, properties) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + } + + #[test] + fn matcher_treats_nonempty_unicode_names_as_opaque_data() { + for (title, labels) in [ + ("e\u{301}", ["é", "E\u{301}", "ë"]), + ("हिन्दी", ["ไทย", "עברית", "فارسی"]), + ("日本語", ["한국어", "简体中文", "繁體中文"]), + ("\u{2067}العربية\u{2069}", ["\u{2066}A\u{2069}", "👩🏽‍💻", "𐐷"]), + ("Հայերեն", ["ქართული", "አማርኛ", "বাংলা"]), + ("A\u{200d}B", ["無", "⠿", "✅"]), + ] { + assert_eq!( + exact_allow_button_with(&prompt(title, labels), properties).unwrap(), + Some(12) + ); + } + } + + #[test] + fn matcher_refuses_when_accessible_names_cannot_bind_the_native_surface() { + let mut nodes = prompt("placeholder", ["A", "B", "C"]); + for node in &mut nodes { + node.name = None; + } + + assert_eq!(exact_allow_button_with(&nodes, properties).unwrap(), None); + } + + #[test] + fn matcher_deduplicates_repeated_uia_walk_rows_by_exact_geometry() { + let mut nodes = prompt("任何语言", ["A", "B", "C"]); + let mut duplicate = nodes[4].clone(); + duplicate.element_ptr = 99; + nodes.push(duplicate); + assert_eq!( + exact_allow_button_with(&nodes, properties).unwrap(), + Some(12) + ); + } + + #[test] + fn matcher_is_direction_independent_for_rtl_dialog_layouts() { + let mut nodes = prompt( + "هل تريد السماح بتصحيح الأخطاء عن بُعد؟", + ["تعطيل", "سماح", "إلغاء"], + ); + nodes[3].rect = Some((600, 343, 780, 399)); + nodes[4].rect = Some((299, 343, 396, 399)); + nodes[5].rect = Some((191, 343, 288, 399)); + assert_eq!( + exact_allow_button_with(&nodes, properties).unwrap(), + Some(12) + ); + } + + #[test] + fn matcher_does_not_require_focus_when_the_browser_is_inactive() { + let nodes = prompt( + "¿Permitir la depuración remota?", + ["Desactivar", "Permitir", "Cancelar"], + ); + assert_eq!( + exact_allow_button_with(&nodes, |_| { + Ok((CHROMIUM_DIALOG_BUTTON_CLASS.to_owned(), false)) + }) + .unwrap(), + Some(12) + ); + } + + #[test] + fn matcher_refuses_ambiguous_focus_or_button_geometry() { + let nodes = prompt("Qualsiasi lingua", ["A", "B", "C"]); + assert_eq!( + exact_allow_button_with(&nodes, |element_ptr| { + Ok((CHROMIUM_DIALOG_BUTTON_CLASS.to_owned(), element_ptr >= 12)) + }) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + + let mut equidistant = nodes; + equidistant[3].rect = Some((-5700, 343, -5560, 399)); + assert_eq!( + exact_allow_button_with(&equidistant, properties) + .unwrap_err() + .code, BrowserRefusalCode::BrowserWrongTargetRefused ); } #[test] fn matcher_ignores_a_spoofed_prompt_inside_web_content() { - let mut nodes = prompt(); - nodes.insert(0, node("Document", "Example page", &[])); - nodes[0].element_index = Some(42); - for child in &mut nodes[1..] { - child.parent_element_index = Some(42); + let mut nodes = prompt("Permitir depuração remota?", ["A", "B", "C"]); + for child in &mut nodes { child.in_web_content = true; } - assert_eq!(exact_allow_button(&nodes).unwrap(), None); + assert_eq!(exact_allow_button_with(&nodes, properties).unwrap(), None); + } + + #[test] + fn matcher_refuses_chromium_buttons_outside_the_bound_prompt_subtree() { + let title = "opaque consent surface"; + let nodes = vec![ + dialog_node("Pane", title, 2), + dialog_node("Window", title, 3), + dialog_node("Text", title, 7), + dialog_node("Pane", "unrelated native sibling", 2), + button("A", 11, (-6066, 343, -5886, 399)), + button("B", 12, (-5657, 343, -5560, 399)), + button("C", 13, (-5549, 343, -5452, 399)), + ]; + + assert_eq!( + exact_allow_button_with(&nodes, properties) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + } + + #[test] + fn matcher_requires_the_matching_pane_to_be_a_window_ancestor() { + let title = "opaque consent surface"; + let nodes = vec![ + dialog_node("Pane", title, 2), + dialog_node("Pane", "different native container", 2), + dialog_node("Window", title, 3), + dialog_node("Text", title, 7), + button("A", 11, (-6066, 343, -5886, 399)), + button("B", 12, (-5657, 343, -5560, 399)), + button("C", 13, (-5549, 343, -5452, 399)), + ]; + + assert_eq!(exact_allow_button_with(&nodes, properties).unwrap(), None); + } + + #[test] + fn matcher_requires_the_native_prompt_surface_and_chromium_button_class() { + let mut nodes = prompt("원격 디버깅을 허용하시겠습니까?", ["A", "B", "C"]); + nodes[0].name = Some("different native pane".to_owned()); + assert_eq!(exact_allow_button_with(&nodes, properties).unwrap(), None); + + let nodes = prompt("원격 디버깅을 허용하시겠습니까?", ["A", "B", "C"]); + assert_eq!( + exact_allow_button_with(&nodes, |element_ptr| { + Ok(( + if element_ptr == 12 { + "RendererButton" + } else { + CHROMIUM_DIALOG_BUTTON_CLASS + } + .to_owned(), + element_ptr == 13, + )) + }) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); } } diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs index 7dfc66881e..7f8fd9c120 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs @@ -986,6 +986,34 @@ async fn browser_websocket_url(port: u16) -> Option { .flatten() } +fn select_provisional_setup_port( + ports: &[u16], + listeners_before: &[u16], + setup_was_already_enabled: bool, +) -> Result, BrowserRefusal> { + let correlated = ports + .iter() + .copied() + .filter(|port| !listeners_before.contains(port)) + .collect::>(); + match correlated.as_slice() { + [port] => Ok(Some(*port)), + [] if setup_was_already_enabled => match ports { + [] => Ok(None), + [port] => Ok(Some(*port)), + _ => Err(refusal( + BrowserRefusalCode::BrowserBindingAmbiguous, + "the pre-enabled browser setup exposed multiple existing exact-pid loopback listeners", + )), + }, + [] => Ok(None), + _ => Err(refusal( + BrowserRefusalCode::BrowserBindingAmbiguous, + "the approved setup action exposed multiple newly correlated exact-pid listeners", + )), + } +} + const ENDPOINT_DISCOVERY_ATTEMPTS: usize = 4; const ENDPOINT_DISCOVERY_RETRY_DELAY: Duration = Duration::from_millis(100); @@ -1662,6 +1690,7 @@ impl BrowserPlatform for WindowsBrowserPlatform { })??; let opened_setup_page = handle.opened_setup_page; let enabled_remote_debugging = handle.enabled_remote_debugging; + let setup_was_already_enabled = !enabled_remote_debugging; let focused_setup_address_field = handle.focused_setup_address_field; let foregrounded_window = handle.foregrounded_window; let injected_global_input = handle.injected_global_input; @@ -1695,25 +1724,22 @@ impl BrowserPlatform for WindowsBrowserPlatform { } } if endpoints.is_empty() { - let correlated = ports - .iter() - .copied() - .filter(|port| !listeners_before.contains(port)) - .collect::>(); - if let [port] = correlated.as_slice() { - endpoints.push(( - *port, + match select_provisional_setup_port( + &ports, + &listeners_before, + setup_was_already_enabled, + ) { + Ok(Some(port)) => endpoints.push(( + port, format!("ws://127.0.0.1:{port}/devtools/browser"), - "new exact browser-pid listener correlated with approved setup", - )); - } else if correlated.len() > 1 { - break Err(refusal( - BrowserRefusalCode::BrowserBindingAmbiguous, - format!( - "{} exposed multiple newly correlated exact-pid listeners", - descriptor.product_name - ), - )); + if listeners_before.contains(&port) { + "unique existing exact browser-pid listener bound to a pre-enabled exact setup page" + } else { + "new exact browser-pid listener correlated with approved setup" + }, + )), + Ok(None) => {} + Err(error) => break Err(error), } } match endpoints.as_slice() { @@ -2384,6 +2410,18 @@ mod tests { assert_eq!(literal_loopback_websocket_port("ws://127.0.0.1:9222"), None); } + #[test] + fn pre_enabled_setup_reuses_one_exact_pid_port_before_consent() { + assert_eq!( + select_provisional_setup_port(&[9222], &[9222], true).unwrap(), + Some(9222) + ); + assert_eq!( + select_provisional_setup_port(&[9222], &[9222], false).unwrap(), + None + ); + } + #[test] fn windows_command_line_parser_preserves_quoted_profile_paths() { let args = parse_windows_command_line( @@ -2423,6 +2461,16 @@ mod tests { ); } + #[test] + fn pre_enabled_setup_refuses_multiple_existing_exact_pid_ports() { + assert_eq!( + select_provisional_setup_port(&[9222, 9333], &[9222, 9333], true) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserBindingAmbiguous + ); + } + #[test] fn legacy_setup_endpoint_must_remain_exact_during_active_port_preference_window() { let start = std::time::Instant::now(); diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs index f7213bb60d..8e80f51aea 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_setup_ui.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Mutex, OnceLock}, }; @@ -19,74 +19,197 @@ use windows::Win32::UI::Accessibility::{ use crate::uia::UiaNode; +// Native Chromium chrome is localized, so its accessible names are diagnostic +// text rather than a stable automation contract. Bootstrap against the exact +// approved HWND, native-vs-renderer boundary, control type, supported action, +// uniqueness, and exact post-action state instead of maintaining language +// allowlists or accepting fuzzy labels. The internal setup page follows the +// same rule: its native URL and web-control topology are contracts; its +// localized document, heading, and checkbox names are not. + fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefusal { BrowserRefusal::new(code, message) } -fn field_equals(node: &UiaNode, expected: &str) -> bool { - [ - node.name.as_deref(), - node.value.as_deref(), - node.automation_id.as_deref(), - node.help_text.as_deref(), - ] - .into_iter() - .flatten() - .any(|value| value.trim().eq_ignore_ascii_case(expected)) -} - fn release_nodes(nodes: &[UiaNode]) { for node in nodes.iter().filter(|node| node.element_ptr != 0) { unsafe { drop(IUIAutomationElement::from_raw(node.element_ptr as *mut _)) }; } } -fn unique_actionable( +fn unique_web_actionable( nodes: &[UiaNode], control_type: &str, - label: &str, action: &str, ) -> Result, BrowserRefusal> { let matches = nodes .iter() .filter(|node| { - node.control_type == control_type - && field_equals(node, label) + node.in_web_content + && node.control_type == control_type && node.actions.iter().any(|value| value == action) + && node.enabled != Some(false) + && node.element_ptr != 0 }) .map(|node| node.element_ptr) - .collect::>(); - match matches.as_slice() { - [] => Ok(None), - [element] => Ok(Some(*element)), + .collect::>(); + match matches.len() { + 0 => Ok(None), + 1 => Ok(matches.into_iter().next()), _ => Err(refusal( BrowserRefusalCode::BrowserWrongTargetRefused, - format!("multiple exact {control_type} controls matched {label:?}"), + format!( + "multiple web {control_type} controls expose the exact {action} action on the setup page" + ), )), } } -fn setup_page_proven(nodes: &[UiaNode], descriptor: &BrowserSetupDescriptor) -> bool { - let exact_url = nodes.iter().any(|node| { - node.control_type == "Edit" - && field_equals(node, "Address and search bar") +fn unique_native_actionable( + nodes: &[UiaNode], + control_type: &str, + action: &str, +) -> Result, BrowserRefusal> { + unique_native_actionable_with_focus(nodes, control_type, action, element_has_keyboard_focus) +} + +fn element_has_keyboard_focus(element_ptr: usize) -> bool { + if element_ptr == 0 { + return false; + } + let element = unsafe { IUIAutomationElement::from_raw(element_ptr as *mut _) }; + let focused = unsafe { element.CurrentHasKeyboardFocus() } + .ok() + .is_some_and(|value| value.as_bool()); + std::mem::forget(element); + focused +} + +fn unique_native_actionable_with_focus( + nodes: &[UiaNode], + control_type: &str, + action: &str, + mut has_keyboard_focus: impl FnMut(usize) -> bool, +) -> Result, BrowserRefusal> { + let matches = nodes + .iter() + .filter(|node| { + !node.in_web_content + && node.control_type == control_type + && node.actions.iter().any(|value| value == action) + && node.enabled != Some(false) + && node.element_ptr != 0 + }) + .map(|node| node.element_ptr) + .collect::>(); + match matches.len() { + 0 => Ok(None), + 1 => Ok(matches.into_iter().next()), + _ => { + let focused = matches + .into_iter() + .filter(|element| has_keyboard_focus(*element)) + .collect::>(); + match focused.as_slice() { + [element] => Ok(Some(*element)), + _ => Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "multiple native {control_type} controls expose the exact {action} action, \ + and keyboard focus did not identify exactly one" + ), + )), + } + } + } +} + +fn native_tab_count(nodes: &[UiaNode]) -> usize { + nodes + .iter() + .filter(|node| !node.in_web_content && node.control_type == "TabItem") + .filter_map(|node| node.rect) + .filter(|(left, top, right, bottom)| left < right && top < bottom) + .collect::>() + .len() +} + +fn exact_native_new_tab_button(nodes: &[UiaNode]) -> Result, BrowserRefusal> { + let Some(last_tab_index) = nodes.iter().rposition(|node| { + !node.in_web_content + && node.control_type == "TabItem" && node - .value - .as_deref() - .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) - }); - let exact_heading = nodes.iter().any(|node| { - matches!(node.control_type.as_str(), "Header" | "Text") - && field_equals(node, descriptor.page_heading) - }); - let exact_page = nodes.iter().any(|node| { - node.control_type == "Document" - && descriptor - .page_titles - .iter() - .any(|title| field_equals(node, title)) - }); - exact_url && exact_page && exact_heading + .rect + .is_some_and(|(left, top, right, bottom)| left < right && top < bottom) + }) else { + return Ok(None); + }; + let last_tab = &nodes[last_tab_index]; + let successor_index = + (last_tab_index + 1..nodes.len()).find(|index| nodes[*index].depth <= last_tab.depth); + let Some(successor) = successor_index.map(|index| &nodes[index]) else { + return Ok(None); + }; + let vertically_overlaps_tab_row = match (last_tab.rect, successor.rect) { + (Some((_, tab_top, _, tab_bottom)), Some((_, button_top, _, button_bottom))) => { + tab_top < button_bottom && button_top < tab_bottom + } + _ => false, + }; + if successor.in_web_content + || successor.control_type != "Button" + || successor.enabled == Some(false) + || successor.element_ptr == 0 + || !successor.actions.iter().any(|action| action == "invoke") + || !vertically_overlaps_tab_row + { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the native tab strip did not expose one exact structural new-tab action", + )); + } + Ok(Some(successor.element_ptr)) +} + +fn stable_native_tab_count(hwnd: u64, initial_count: usize) -> Result { + let deadline = Instant::now() + Duration::from_secs(3); + let mut previous = initial_count; + loop { + std::thread::sleep(Duration::from_millis(100)); + let tree = crate::uia::walk_tree(hwnd, None); + let current = native_tab_count(&tree.nodes); + release_nodes(&tree.nodes); + if current > 0 && current == previous { + return Ok(current); + } + previous = current; + if Instant::now() >= deadline { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the approved Chromium window did not expose a stable native tab topology", + )); + } + } +} + +fn setup_page_proven(nodes: &[UiaNode], descriptor: &BrowserSetupDescriptor) -> bool { + let exact_url_count = nodes + .iter() + .filter(|node| { + !node.in_web_content + && node.control_type == "Edit" + && node.actions.iter().any(|value| value == "set_value") + && node + .value + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case(descriptor.setup_url)) + }) + .count(); + let document_count = nodes + .iter() + .filter(|node| node.control_type == "Document" && !node.in_web_content) + .count(); + exact_url_count == 1 && document_count == 1 } fn exact_setup_checkbox( @@ -96,43 +219,43 @@ fn exact_setup_checkbox( if !setup_page_proven(nodes, descriptor) { return Ok(None); } - unique_actionable(nodes, "CheckBox", descriptor.checkbox_label, "toggle") + unique_web_actionable(nodes, "CheckBox", "toggle") } -unsafe fn invoke(element_ptr: usize) -> Result<(), BrowserRefusal> { +unsafe fn set_value(element_ptr: usize, value: &str) -> Result<(), BrowserRefusal> { let element = IUIAutomationElement::from_raw(element_ptr as *mut _); let result = element - .GetCurrentPattern(UIA_InvokePatternId) - .and_then(|pattern| pattern.cast::()) - .and_then(|pattern| pattern.Invoke()); + .GetCurrentPattern(UIA_ValuePatternId) + .and_then(|pattern| pattern.cast::()) + .and_then(|pattern| pattern.SetValue(&BSTR::from(value))); std::mem::forget(element); result.map_err(|error| { refusal( BrowserRefusalCode::BrowserWrongTargetRefused, - format!("the exact UIA Invoke action failed: {error}"), + format!("the exact UIA Value action failed: {error}"), ) }) } -unsafe fn set_value(element_ptr: usize, value: &str) -> Result<(), BrowserRefusal> { +fn force_setup_foreground(target: windows::Win32::Foundation::HWND) -> (bool, bool) { + unsafe { crate::input::force_foreground_assisted(target) } +} + +unsafe fn invoke(element_ptr: usize, description: &str) -> Result<(), BrowserRefusal> { let element = IUIAutomationElement::from_raw(element_ptr as *mut _); let result = element - .GetCurrentPattern(UIA_ValuePatternId) - .and_then(|pattern| pattern.cast::()) - .and_then(|pattern| pattern.SetValue(&BSTR::from(value))); + .GetCurrentPattern(UIA_InvokePatternId) + .and_then(|pattern| pattern.cast::()) + .and_then(|pattern| pattern.Invoke()); std::mem::forget(element); result.map_err(|error| { refusal( BrowserRefusalCode::BrowserWrongTargetRefused, - format!("the exact UIA Value action failed: {error}"), + format!("the exact {description} UIA Invoke action failed: {error}"), ) }) } -fn force_setup_foreground(target: windows::Win32::Foundation::HWND) -> (bool, bool) { - unsafe { crate::input::force_foreground_assisted(target) } -} - fn confirm_setup_navigation( hwnd: u64, element_ptr: usize, @@ -400,51 +523,49 @@ pub fn enable( enable_attempted: false, }, Ok(None) => { - let tab_count_before = initial - .nodes - .iter() - .filter(|node| node.control_type == "TabItem") - .count(); - let new_tab = match unique_actionable(&initial.nodes, "Button", "New Tab", "invoke") { - Ok(Some(element)) => element, - Ok(None) => { - release_nodes(&initial.nodes); - return Err(refusal( - BrowserRefusalCode::BrowserWrongTargetRefused, - format!( - "the approved {} window has no exact New Tab button", - descriptor.product_name - ), - )); - } - Err(error) => { - release_nodes(&initial.nodes); - return Err(error); - } - }; - let invoked = unsafe { invoke(new_tab) }; + let initial_tab_count = native_tab_count(&initial.nodes); release_nodes(&initial.nodes); - invoked?; + let tab_count_before = stable_native_tab_count(hwnd, initial_tab_count)?; let mut handle = SetupUiHandle { hwnd, descriptor, - opened_setup_page: true, + opened_setup_page: false, enabled_remote_debugging: false, focused_setup_address_field: false, foregrounded_window: false, injected_global_input: false, enable_attempted: false, }; + let tab_tree = crate::uia::walk_tree(hwnd, None); + let new_tab_button = match exact_native_new_tab_button(&tab_tree.nodes) { + Ok(Some(element)) => element, + Ok(None) => { + release_nodes(&tab_tree.nodes); + return Err(handle.abort(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "the exact {} window has no structural native new-tab action", + descriptor.product_name + ), + ))); + } + Err(error) => { + release_nodes(&tab_tree.nodes); + return Err(handle.abort(error)); + } + }; + if let Err(error) = unsafe { invoke(new_tab_button, "native new-tab button") } { + release_nodes(&tab_tree.nodes); + return Err(handle.abort(error)); + } + release_nodes(&tab_tree.nodes); + handle.opened_setup_page = true; let deadline = Instant::now() + Duration::from_secs(3); let mut created = loop { let tree = crate::uia::walk_tree(hwnd, None); - let tab_count_after = tree - .nodes - .iter() - .filter(|node| node.control_type == "TabItem") - .count(); + let tab_count_after = native_tab_count(&tree.nodes); if tab_count_after == tab_count_before + 1 { break tree; } @@ -460,23 +581,18 @@ pub fn enable( } std::thread::sleep(Duration::from_millis(100)); }; - let omnibox = unique_actionable( - &created.nodes, - "Edit", - "Address and search bar", - "set_value", - )? - .ok_or_else(|| { - refusal( - BrowserRefusalCode::BrowserWrongTargetRefused, - format!( - "the approved {} window has no exact address-and-search field", - descriptor.product_name - ), - ) - }); - let omnibox = match omnibox { - Ok(element) => element, + let omnibox = match unique_native_actionable(&created.nodes, "Edit", "set_value") { + Ok(Some(element)) => element, + Ok(None) => { + release_nodes(&created.nodes); + return Err(handle.abort(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!( + "the approved {} window has no unique native editable address field", + descriptor.product_name + ), + ))); + } Err(error) => { release_nodes(&created.nodes); return Err(handle.abort(error)); @@ -488,24 +604,30 @@ pub fn enable( } release_nodes(&created.nodes); created = crate::uia::walk_tree(hwnd, None); - let refreshed_omnibox = created - .nodes - .iter() - .find(|node| { - node.control_type == "Edit" - && field_equals(node, "Address and search bar") - && node.value.as_deref().is_some_and(|value| { - value.trim().eq_ignore_ascii_case(descriptor.setup_url) - }) - }) - .map(|node| node.element_ptr); - let Some(refreshed_omnibox) = refreshed_omnibox else { - release_nodes(&created.nodes); - return Err(handle.abort(refusal( - BrowserRefusalCode::BrowserWrongTargetRefused, - "the exact address field did not retain the setup URL", - ))); - }; + let refreshed_omnibox = + match unique_native_actionable(&created.nodes, "Edit", "set_value") { + Ok(Some(element)) + if created.nodes.iter().any(|node| { + node.element_ptr == element + && node.value.as_deref().is_some_and(|value| { + value.trim().eq_ignore_ascii_case(descriptor.setup_url) + }) + }) => + { + element + } + Ok(_) => { + release_nodes(&created.nodes); + return Err(handle.abort(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + "the unique native address field did not retain the exact setup URL", + ))); + } + Err(error) => { + release_nodes(&created.nodes); + return Err(handle.abort(error)); + } + }; if let Err(error) = confirm_setup_navigation( hwnd, refreshed_omnibox, @@ -606,7 +728,9 @@ mod tests { } #[test] - fn checkbox_requires_exact_url_heading_and_unique_toggle() { + fn checkbox_requires_exact_internal_url_and_unique_web_toggle() { + let mut checkbox = node("CheckBox", descriptor().checkbox_label, None, &["toggle"]); + checkbox.in_web_content = true; let nodes = vec![ node( "Edit", @@ -616,10 +740,21 @@ mod tests { ), node("Document", descriptor().page_titles[0], None, &[]), node("Header", descriptor().page_heading, None, &[]), - node("CheckBox", descriptor().checkbox_label, None, &["toggle"]), + checkbox, ]; assert_eq!(exact_setup_checkbox(&nodes, descriptor()).unwrap(), Some(7)); + let mut localized = nodes.clone(); + localized[0].name = Some("アドレス検索バー".to_owned()); + localized[1].name = Some("远程调试页面".to_owned()); + localized[2].name = Some("Удалённая отладка".to_owned()); + localized[3].name = Some("السماح بتصحيح الأخطاء لهذا المتصفح".to_owned()); + localized[3].in_web_content = true; + assert_eq!( + exact_setup_checkbox(&localized, descriptor()).unwrap(), + Some(7) + ); + let mut wrong_url = nodes.clone(); wrong_url[0].value = Some("https://example.test/".to_owned()); assert_eq!( @@ -627,4 +762,224 @@ mod tests { None ); } + + #[test] + fn setup_page_names_are_opaque_across_unicode_scripts_and_normalization() { + let samples = [ + ("e\u{301}", "é", "✅"), + ("हिन्दी", "ไทย", "עברית"), + ("日本語", "한국어", "简体中文"), + ("\u{2067}العربية\u{2069}", "فارسی", "اردو"), + ("Հայերեն", "ქართული", "አማርኛ"), + ("👩🏽‍💻", "A\u{200d}B", "𐐷"), + ("", "", ""), + ]; + for (document_name, heading_name, checkbox_name) in samples { + let mut checkbox = node("CheckBox", checkbox_name, None, &["toggle"]); + checkbox.in_web_content = true; + let nodes = vec![ + node( + "Edit", + "opaque native address field", + Some(descriptor().setup_url), + &["set_value"], + ), + node("Document", document_name, None, &[]), + node("Header", heading_name, None, &[]), + checkbox, + ]; + assert_eq!(exact_setup_checkbox(&nodes, descriptor()).unwrap(), Some(7)); + } + } + + #[test] + fn setup_page_does_not_require_accessible_names() { + let mut checkbox = node("CheckBox", "placeholder", None, &["toggle"]); + checkbox.name = None; + checkbox.in_web_content = true; + let mut address = node( + "Edit", + "placeholder", + Some(descriptor().setup_url), + &["set_value"], + ); + address.name = None; + let mut document = node("Document", "placeholder", None, &[]); + document.name = None; + let nodes = vec![address, document, checkbox]; + + assert_eq!(exact_setup_checkbox(&nodes, descriptor()).unwrap(), Some(7)); + } + + #[test] + fn setup_page_refuses_ambiguous_web_toggles_without_reading_their_names() { + let mut first = node("CheckBox", "A", None, &["toggle"]); + first.element_ptr = 41; + first.in_web_content = true; + let mut second = node("CheckBox", "B", None, &["toggle"]); + second.element_ptr = 42; + second.in_web_content = true; + let nodes = vec![ + node( + "Edit", + "address", + Some(descriptor().setup_url), + &["set_value"], + ), + node("Document", "opaque", None, &[]), + first, + second, + ]; + + assert_eq!( + exact_setup_checkbox(&nodes, descriptor()).unwrap_err().code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + } + + #[test] + fn native_address_control_is_language_independent_without_trusting_web_content() { + let mut native_address = node( + "Edit", + "アドレス検索バー", + Some("chrome://inspect/#remote-debugging"), + &["set_value"], + ); + native_address.element_ptr = 11; + let mut renderer_spoof = node( + "Edit", + "アドレス検索バー", + Some("chrome://inspect/#remote-debugging"), + &["set_value"], + ); + renderer_spoof.element_ptr = 12; + renderer_spoof.in_web_content = true; + + assert_eq!( + unique_native_actionable(&[native_address, renderer_spoof], "Edit", "set_value") + .unwrap(), + Some(11) + ); + } + + #[test] + fn native_browser_controls_still_refuse_ambiguous_structural_matches() { + let mut first = node("Edit", "Adress- und Suchleiste", None, &["set_value"]); + first.element_ptr = 31; + let mut second = node( + "Edit", + "Barre d'adresse et de recherche", + None, + &["set_value"], + ); + second.element_ptr = 32; + + let error = + unique_native_actionable_with_focus(&[first, second], "Edit", "set_value", |_| false) + .unwrap_err(); + assert_eq!(error.code, BrowserRefusalCode::BrowserWrongTargetRefused); + } + + #[test] + fn native_address_control_uses_unique_keyboard_focus_when_edge_exposes_multiple_edits() { + let mut first = node("Edit", "opaque first", None, &["set_value"]); + first.element_ptr = 31; + let mut second = node("Edit", "opaque second", None, &["set_value"]); + second.element_ptr = 32; + + assert_eq!( + unique_native_actionable_with_focus(&[first, second], "Edit", "set_value", |element| { + element == 32 + },) + .unwrap(), + Some(32) + ); + } + + #[test] + fn native_address_control_refuses_multiple_focused_edits() { + let mut first = node("Edit", "opaque first", None, &["set_value"]); + first.element_ptr = 31; + let mut second = node("Edit", "opaque second", None, &["set_value"]); + second.element_ptr = 32; + + assert_eq!( + unique_native_actionable_with_focus(&[first, second], "Edit", "set_value", |_| true,) + .unwrap_err() + .code, + BrowserRefusalCode::BrowserWrongTargetRefused + ); + } + + #[test] + fn setup_page_proof_rejects_duplicate_native_exact_urls() { + let mut nodes = vec![ + node( + "Edit", + "Barra de direcciones y de búsqueda", + Some(descriptor().setup_url), + &["set_value"], + ), + node("Document", descriptor().page_titles[0], None, &[]), + node("Header", descriptor().page_heading, None, &[]), + ]; + let mut duplicate = nodes[0].clone(); + duplicate.element_ptr = 99; + nodes.push(duplicate); + + assert!(!setup_page_proven(&nodes, descriptor())); + } + + #[test] + fn native_tab_count_is_structural_and_deduplicates_repeated_uia_rows() { + let mut first = node("TabItem", "新标签页", None, &[]); + first.rect = Some((10, 10, 110, 40)); + let duplicate = first.clone(); + let mut second = node("TabItem", "Neue Registerkarte", None, &[]); + second.rect = Some((120, 10, 220, 40)); + let mut renderer_spoof = node("TabItem", "Tab", None, &[]); + renderer_spoof.rect = Some((230, 10, 330, 40)); + renderer_spoof.in_web_content = true; + let mut invalid = node("TabItem", "Onglet", None, &[]); + invalid.rect = Some((0, 0, 0, 0)); + + assert_eq!( + native_tab_count(&[first, duplicate, second, renderer_spoof, invalid]), + 2 + ); + } + + #[test] + fn native_new_tab_button_is_the_strict_successor_of_the_tab_strip() { + let mut first = node("TabItem", "opaque-1", None, &["select"]); + first.element_index = Some(10); + first.element_ptr = 10; + first.depth = 8; + first.rect = Some((10, 10, 110, 40)); + let mut first_close = node("Button", "opaque-close-1", None, &["invoke"]); + first_close.element_index = Some(11); + first_close.element_ptr = 11; + first_close.depth = 9; + first_close.parent_element_index = Some(10); + let mut second = node("TabItem", "opaque-2", None, &["select"]); + second.element_index = Some(20); + second.element_ptr = 20; + second.depth = 8; + second.rect = Some((120, 10, 220, 40)); + let mut second_close = node("Button", "opaque-close-2", None, &["invoke"]); + second_close.element_index = Some(21); + second_close.element_ptr = 21; + second_close.depth = 9; + second_close.parent_element_index = Some(20); + let mut new_tab = node("Button", "opaque-new-tab", None, &["invoke"]); + new_tab.element_ptr = 30; + new_tab.depth = 6; + new_tab.rect = Some((220, 10, 260, 40)); + + assert_eq!( + exact_native_new_tab_button(&[first, first_close, second, second_close, new_tab,]) + .unwrap(), + Some(30) + ); + } } From 56f13c360a8b678960747c50f2680e40c14ae346 Mon Sep 17 00:00:00 2001 From: Berzerker5653 Date: Wed, 19 Aug 2026 17:17:46 -0600 Subject: [PATCH 081/117] fix(cua-driver): block uinput pointer hotplug on KDE X11 (#2888) Co-authored-by: Berzerker5653 <12285165+Berzerker5653@users.noreply.github.com> --- .../crates/platform-linux/src/input/mod.rs | 169 +++++++++++++++++- 1 file changed, 163 insertions(+), 6 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs index 4983a721bb..9377555006 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/input/mod.rs @@ -390,8 +390,77 @@ fn real_pointer_capabilities_available( server_supported: bool, xvfb: bool, uinput_accessible: bool, + unsafe_hotplug_session: bool, ) -> bool { - server_supported && !xvfb && uinput_accessible + server_supported && !xvfb && uinput_accessible && !unsafe_hotplug_session +} + +fn nonempty(value: Option<&str>) -> bool { + value.is_some_and(|value| !value.trim().is_empty()) +} + +fn desktop_value_is_kde(value: Option<&str>) -> bool { + value.is_some_and(|value| { + value + .split([':', ';', ',']) + .map(str::trim) + .any(|token| token.eq_ignore_ascii_case("kde") || token.eq_ignore_ascii_case("plasma")) + }) +} + +/// KDE Plasma 6 / Qt 6.11 applications on X11 can crash session-wide when an +/// ephemeral uinput pointer is hotplugged into Xorg. Foreground input does not +/// need that device: the click, drag, scroll, and keyboard tools already use +/// XTEST after activating the target window. Disable only the MPX/uinput +/// capability here so callers retain their existing foreground escalation and +/// XSendEvent fallback behavior. +fn kde_x11_uinput_hotplug_is_unsafe( + session_type: Option<&str>, + current_desktop: Option<&str>, + session_desktop: Option<&str>, + desktop_session: Option<&str>, + kde_full_session: Option<&str>, + display: Option<&str>, + wayland_display: Option<&str>, +) -> bool { + let explicit_x11 = session_type.is_some_and(|value| value.eq_ignore_ascii_case("x11")); + let explicit_wayland = session_type.is_some_and(|value| value.eq_ignore_ascii_case("wayland")); + if explicit_wayland || (!explicit_x11 && nonempty(wayland_display)) { + return false; + } + + let x11 = explicit_x11 || nonempty(display); + let kde = desktop_value_is_kde(current_desktop) + || desktop_value_is_kde(session_desktop) + || desktop_value_is_kde(desktop_session) + || kde_full_session.is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }); + + x11 && kde +} + +fn kde_x11_uinput_hotplug_is_unsafe_from_env() -> bool { + let session_type = std::env::var("XDG_SESSION_TYPE").ok(); + let current_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok(); + let session_desktop = std::env::var("XDG_SESSION_DESKTOP").ok(); + let desktop_session = std::env::var("DESKTOP_SESSION").ok(); + let kde_full_session = std::env::var("KDE_FULL_SESSION").ok(); + let display = std::env::var("DISPLAY").ok(); + let wayland_display = std::env::var("WAYLAND_DISPLAY").ok(); + + kde_x11_uinput_hotplug_is_unsafe( + session_type.as_deref(), + current_desktop.as_deref(), + session_desktop.as_deref(), + desktop_session.as_deref(), + kde_full_session.as_deref(), + display.as_deref(), + wayland_display.as_deref(), + ) } fn uinput_accessible() -> bool { @@ -403,6 +472,13 @@ fn uinput_accessible() -> bool { } pub fn real_pointer_input_available() -> bool { + // Do not even probe /dev/uinput on an affected KDE/X11 session. Creating + // the device is itself the dangerous operation; a later fallback is too + // late once Xorg has announced the hotplug to Qt clients. + if kde_x11_uinput_hotplug_is_unsafe_from_env() { + return false; + } + // `ensure_master_pointer` creates an XI2 master before attaching the // uinput slave. If this process cannot open /dev/uinput, attempting that // path on every click/scroll would create and abandon an XInput master @@ -418,12 +494,26 @@ pub fn real_pointer_input_available() -> bool { supports_parallel_pointer_injection(display).is_ok(), is_xvfb_process_running(), true, + false, ); unsafe { x11::xlib::XCloseDisplay(display) }; supported } fn ensure_master_pointer(cursor_id: &str) -> Result { + ensure_master_pointer_for_session(cursor_id, kde_x11_uinput_hotplug_is_unsafe_from_env()) +} + +fn ensure_master_pointer_for_session( + cursor_id: &str, + unsafe_hotplug_session: bool, +) -> Result { + if unsafe_hotplug_session { + return Err(uinput_unavailable( + "disabled on KDE Plasma X11; retry with delivery_mode='foreground'", + )); + } + if let Some(ids) = mpx_pointers().lock().unwrap().get(cursor_id).copied() { return Ok(ids); } @@ -2817,7 +2907,8 @@ exit 0"#, #[cfg(test)] mod path_tests { use super::{ - create_uinput_pointer, guarded_uinput_creation, is_uinput_unavailable, master_pointer_name, + create_uinput_pointer, ensure_master_pointer_for_session, guarded_uinput_creation, + is_uinput_unavailable, kde_x11_uinput_hotplug_is_unsafe, master_pointer_name, modifiers_to_state, normalize_uinput_device_name, path_cumulative, point_on_path, real_pointer_capabilities_available, sample_function, slave_pointer_name, EVDEV_UINPUT_NAME_MAX_BYTES, UINPUT_POINTER_SUFFIX, @@ -2929,10 +3020,76 @@ mod path_tests { #[test] fn real_pointer_capabilities_require_uinput_access() { - assert!(real_pointer_capabilities_available(true, false, true)); - assert!(!real_pointer_capabilities_available(true, false, false)); - assert!(!real_pointer_capabilities_available(false, false, true)); - assert!(!real_pointer_capabilities_available(true, true, true)); + assert!(real_pointer_capabilities_available( + true, false, true, false + )); + assert!(!real_pointer_capabilities_available( + true, false, false, false + )); + assert!(!real_pointer_capabilities_available( + false, false, true, false + )); + assert!(!real_pointer_capabilities_available( + true, true, true, false + )); + assert!(!real_pointer_capabilities_available( + true, false, true, true + )); + } + + #[test] + fn kde_x11_sessions_disable_uinput_pointer_hotplug() { + assert!(kde_x11_uinput_hotplug_is_unsafe( + Some("x11"), + Some("KDE"), + None, + None, + None, + Some(":0"), + None, + )); + assert!(kde_x11_uinput_hotplug_is_unsafe( + Some("x11"), + Some("KDE"), + None, + None, + None, + Some(":0"), + Some("wayland-0"), + )); + assert!(kde_x11_uinput_hotplug_is_unsafe( + None, + None, + Some("plasma"), + None, + Some("true"), + Some(":1"), + None, + )); + + assert!(!kde_x11_uinput_hotplug_is_unsafe( + Some("wayland"), + Some("KDE"), + None, + None, + Some("true"), + Some(":0"), + Some("wayland-0"), + )); + assert!(!kde_x11_uinput_hotplug_is_unsafe( + Some("x11"), + Some("GNOME"), + None, + None, + None, + Some(":0"), + None, + )); + + let error = ensure_master_pointer_for_session("regression-test", true) + .expect_err("the creation choke point must refuse before opening X11 or uinput"); + assert!(is_uinput_unavailable(&error)); + assert!(error.to_string().contains("delivery_mode='foreground'")); } #[test] From f8ce0647027d23315333cb6f815349ca4ce418e5 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:21:35 +0800 Subject: [PATCH 082/117] docs: require canonical repository branches (#3273) --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f01e628422..f4bf1b20f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,11 @@ understand, reproduce, or continue the work. superseded; - use one issue or RFC as the problem/decision record and one isolated branch or worktree per implementation workstream; +- when the authenticated GitHub account has write access, create and push the + work branch directly in the canonical repository. Do not default to a personal + fork merely because a fork remote exists. Use a fork only when write access is + unavailable or the maintainer explicitly requests one, and verify the pull + request head owner before reporting it; - keep the linked pull request description current with scope, progress, validation evidence, known gaps, and blockers instead of posting noisy periodic status comments; From 2c8fa2a73d87d858520a3acfabc9b6a54479960c Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:06:42 +0800 Subject: [PATCH 083/117] fix(cua-driver): preserve embedded telemetry preference (#3277) --- .../crates/cua-driver-sdk/src/embedded.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs index aa1aa8d307..c205a53680 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs @@ -852,6 +852,8 @@ pub(crate) fn allowed_environment_name(name: &str) -> bool { | "DBUS_SESSION_BUS_ADDRESS" | "XAUTHORITY" | "CUA_LOG" + | "CUA_DRIVER_RS_TELEMETRY_ENABLED" + | "CUA_TELEMETRY_ENABLED" ) } @@ -1196,6 +1198,45 @@ mod tests { assert!(!allowed_environment_name("NODE_OPTIONS")); } + #[test] + fn telemetry_preferences_are_inherited_and_overridable() { + assert!(allowed_environment_name("CUA_DRIVER_RS_TELEMETRY_ENABLED")); + assert!(allowed_environment_name("cua_telemetry_enabled")); + + let inherited = [ + ("CUA_DRIVER_RS_TELEMETRY_ENABLED".into(), "1".into()), + ("CUA_TELEMETRY_ENABLED".into(), "true".into()), + ]; + let values = merge_safe_environment(inherited.clone(), &[]); + assert!(values.iter().any(|variable| { + variable.name == "CUA_DRIVER_RS_TELEMETRY_ENABLED" && variable.value == "1" + })); + assert!(values.iter().any(|variable| { + variable.name == "CUA_TELEMETRY_ENABLED" && variable.value == "true" + })); + + let values = merge_safe_environment( + inherited, + &[ + EmbeddedEnvironmentVariable { + name: "CUA_DRIVER_RS_TELEMETRY_ENABLED".into(), + value: "0".into(), + }, + EmbeddedEnvironmentVariable { + name: "CUA_TELEMETRY_ENABLED".into(), + value: "false".into(), + }, + ], + ); + + assert!(values.iter().any(|variable| { + variable.name == "CUA_DRIVER_RS_TELEMETRY_ENABLED" && variable.value == "0" + })); + assert!(values.iter().any(|variable| { + variable.name == "CUA_TELEMETRY_ENABLED" && variable.value == "false" + })); + } + #[test] fn managed_environment_is_inherited_case_insensitively_but_never_overridden() { assert!(inherited_managed_environment_name( From b25f8290e9cd28622c0cff64d3de0351b9d9ee60 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:58:00 +0800 Subject: [PATCH 084/117] docs: recommend Hermes built-in computer use (#3274) * docs: recommend Hermes built-in computer use * docs: clarify Hermes installation verification Co-authored-by: Francesco Bonacci --------- Co-authored-by: Francesco Bonacci --- .../choose-a-cua-driver-integration.mdx | 8 ++-- .../driver/connect-your-agent.mdx | 1 - .../docs/tutorials/drive-your-first-app.mdx | 22 ++++----- docs/content/docs/use-cua-with/hermes.mdx | 47 ++++++++++++++----- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/content/docs/concepts/choose-a-cua-driver-integration.mdx b/docs/content/docs/concepts/choose-a-cua-driver-integration.mdx index aae59d5214..e17d0d99c4 100644 --- a/docs/content/docs/concepts/choose-a-cua-driver-integration.mdx +++ b/docs/content/docs/concepts/choose-a-cua-driver-integration.mdx @@ -36,9 +36,11 @@ The MCP client starts `cua-driver mcp` and keeps its standard input and output open. That authenticated transport receives a private implicit lifecycle session. Closing the transport releases its session state. -This is the normal route for Codex, Claude Code, Cursor, Hermes, and a standard -OpenClaw gateway. Generate the client-specific configuration with -`cua-driver mcp-config --client `. +This is the normal route for Codex, Claude Code, Cursor, and a standard +OpenClaw gateway. Hermes's built-in `computer_use` toolset also uses MCP +internally, but owns the connection and presents a smaller Hermes-native tool +surface. Generate configuration for clients that require direct registration +with `cua-driver mcp-config --client `. On Windows and Linux, bare `cua-driver mcp` owns its runtime. On macOS, it normally proxies to the installed `CuaDriver.app` daemon so Accessibility and diff --git a/docs/content/docs/how-to-guides/driver/connect-your-agent.mdx b/docs/content/docs/how-to-guides/driver/connect-your-agent.mdx index 0d2d89e1e8..917a8daee1 100644 --- a/docs/content/docs/how-to-guides/driver/connect-your-agent.mdx +++ b/docs/content/docs/how-to-guides/driver/connect-your-agent.mdx @@ -179,7 +179,6 @@ Restart Cursor and confirm `cua-driver` appears in the MCP server list. | Antigravity | `cua-driver mcp-config --client antigravity` | Paste into `~/.gemini/config/mcp_config.json`; `--client gemini` is a legacy alias. | | OpenClaw | `cua-driver mcp-config --client openclaw` | Normal gateway-spawned MCP does not inherit OpenClaw.app's macOS permission grants; embedded hosts should use [Embedding](/reference/cua-driver/embedding). | | OpenCode | `cua-driver mcp-config --client opencode` | Configure a real MCP server so screenshots are preserved in image blocks. | -| Hermes | `cua-driver mcp-config --client hermes` | Paste under `mcp_servers` and reload MCP servers in Hermes. | | Pi | `cua-driver mcp-config --client pi` | Pi does not support MCP natively; use one-shot `cua-driver call …` commands from its shell. | | Qwen Code | `cua-driver mcp-config --client qwen` | Supports both a CLI add command and `~/.qwen/settings.json`. | | Factory Droid | `cua-driver mcp-config --client droid` | Supports CLI and JSON config forms. | diff --git a/docs/content/docs/tutorials/drive-your-first-app.mdx b/docs/content/docs/tutorials/drive-your-first-app.mdx index 56c6affe5f..b342b444af 100644 --- a/docs/content/docs/tutorials/drive-your-first-app.mdx +++ b/docs/content/docs/tutorials/drive-your-first-app.mdx @@ -192,25 +192,19 @@ Register Cua Driver with your agent harness once. - Print the Hermes snippet: + Hermes includes a built-in `computer_use` toolset. Verify it and the + available Cua Driver instead of adding a second raw MCP server: ```bash - cua-driver mcp-config --client hermes + hermes computer-use status + hermes computer-use doctor + hermes tools list ``` - Paste it under mcp_servers in ~/.hermes/config.yaml: + If `computer_use` is disabled, enable it for the CLI: - ```yaml - mcp_servers: - cua-driver: - command: "cua-driver" - args: ["mcp"] - ``` - - Then reload MCP servers inside Hermes: - - ```text - /reload-mcp + ```bash + hermes tools enable computer_use --platform cli ``` diff --git a/docs/content/docs/use-cua-with/hermes.mdx b/docs/content/docs/use-cua-with/hermes.mdx index 14cad54d8d..7c4fd4cfad 100644 --- a/docs/content/docs/use-cua-with/hermes.mdx +++ b/docs/content/docs/use-cua-with/hermes.mdx @@ -1,25 +1,50 @@ --- title: Hermes -description: Connect Nous Research's Hermes Agent to Cua Driver over MCP. +description: Use Cua Driver through Hermes Agent's built-in Computer Use toolset. icon: Waypoints --- -Hermes Agent can launch local MCP servers from `~/.hermes/config.yaml`. Generate the current configuration from Cua Driver: +Hermes Agent includes a `computer_use` toolset that wraps Cua Driver with +Hermes-native actions, approvals, image handling, diagnostics, and session +cleanup. Use this built-in integration instead of adding Cua Driver as a second, +raw MCP server. + +Hermes setup normally attempts to install Cua Driver, but that step is +best-effort. Install or repair the driver when needed, then verify the complete +path: + +```bash +hermes computer-use install +hermes computer-use status +hermes computer-use doctor +hermes tools list +``` + +If `computer_use` is disabled, enable it for the Hermes CLI: ```bash -cua-driver mcp-config --client hermes +hermes tools enable computer_use --platform cli ``` -Paste the generated block under `mcp_servers`, then run `/reload-mcp` inside Hermes. The configuration shape is: +Start a session with the Computer Use toolset: -```yaml -mcp_servers: - cua-driver: - command: '/absolute/path/to/cua-driver' - args: ['mcp'] +```bash +hermes -t computer_use chat +``` + +For deeper platform guidance, install Cua Driver's maintained skill pack. Cua +Driver links it into the standard Hermes skill directory: + +```bash +cua-driver skills install +cua-driver skills status ``` -Continue with [Connect your agent to Cua Driver](/how-to-guides/driver/connect-your-agent#other-supported-clients) for runtime and permission guidance. +Raw `cua-driver mcp` registration remains available for driver development and +low-level MCP debugging, but it exposes an overlapping interface without +Hermes's Computer Use wrapper. Do not enable both interfaces by default. The +[`mcp-config` reference](/reference/cua-driver/cli-reference#cua-driver-mcp-config) +records that advanced path. -Upstream: [Hermes Agent documentation](https://hermes-agent.nousresearch.com/docs/). +Upstream: [Hermes Computer Use documentation](https://hermes-agent.nousresearch.com/docs/user-guide/features/computer-use). From 7db1eb0d109141d193a5749028eb2be780b06de8 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:11:46 +0800 Subject: [PATCH 085/117] fix(docs): scope validation to affected generators (#3293) * fix(docs): scope validation to affected generators * fix(docs): enforce scoped generator routing in CI Co-authored-by: Zane Chee --------- Co-authored-by: Francesco Bonacci --- .github/workflows/ci-check-docs.yml | 93 +++---------- TESTING.md | 7 +- docs/README.md | 25 +++- docs/package.json | 2 + scripts/docs-generators/runner.ts | 194 +++++++++++++++++++++++----- 5 files changed, 211 insertions(+), 110 deletions(-) diff --git a/.github/workflows/ci-check-docs.yml b/.github/workflows/ci-check-docs.yml index a2e08d2691..6bb8254850 100644 --- a/.github/workflows/ci-check-docs.yml +++ b/.github/workflows/ci-check-docs.yml @@ -23,6 +23,7 @@ on: - "libs/cuabot/src/**" # Documentation files themselves - "docs/content/docs/reference/cua-driver/**" + - "docs/content/docs/reference/lume/**" - "docs/content/docs/cua/reference/**" - "docs/content/docs/cuabot/reference/**" # Generator scripts @@ -47,9 +48,12 @@ jobs: uses: pnpm/action-setup@v4 - name: Install Node dependencies - run: pnpm install + run: pnpm install --frozen-lockfile working-directory: docs + - name: Verify generator routing and tool resolution + run: docs/node_modules/.bin/tsx scripts/docs-generators/runner.ts --test-routing + - name: Setup Python uses: actions/setup-python@v5 with: @@ -63,67 +67,15 @@ jobs: run: | # Diff all commits in this PR against the base branch BASE_SHA="${{ github.event.pull_request.base.sha }}" - CHANGED_FILES=$(git diff --name-only "$BASE_SHA" HEAD) + CHANGED_FILES_FILE="${RUNNER_TEMP}/docs-changed-files.txt" + git diff --name-only "$BASE_SHA" HEAD > "$CHANGED_FILES_FILE" echo "Changed files:" - echo "$CHANGED_FILES" + cat "$CHANGED_FILES_FILE" - # Check which generators need to run - GENERATORS="" + GENERATORS=$(docs/node_modules/.bin/tsx scripts/docs-generators/runner.ts \ + --changed-files-file "$CHANGED_FILES_FILE") - # Source changes - if echo "$CHANGED_FILES" | grep -q "^libs/cua-driver/rust/crates/\|^libs/cua-driver/rust/Cargo.toml\|^libs/cua-driver/rust/Cargo.lock\|^docs/content/docs/reference/cua-driver/"; then - GENERATORS="$GENERATORS cua-driver" - fi - if echo "$CHANGED_FILES" | grep -q "^libs/lume/src/"; then - GENERATORS="$GENERATORS lume" - fi - if echo "$CHANGED_FILES" | grep -q "^libs/typescript/cua-cli/src/"; then - GENERATORS="$GENERATORS cua-cli" - fi - if echo "$CHANGED_FILES" | grep -q "^libs/python/mcp-server/src/"; then - GENERATORS="$GENERATORS mcp-server" - fi - if echo "$CHANGED_FILES" | grep -q "^libs/python/computer/computer/\|^libs/python/agent/agent/"; then - GENERATORS="$GENERATORS python-sdk" - fi - if echo "$CHANGED_FILES" | grep -q "^libs/cuabot/src/"; then - GENERATORS="$GENERATORS cuabot" - fi - - # Individual generator script changes — only trigger their own generator - if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/cua-driver\.ts"; then - GENERATORS="$GENERATORS cua-driver" - fi - if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/lume\.ts"; then - GENERATORS="$GENERATORS lume" - fi - if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/python-sdk\.ts"; then - GENERATORS="$GENERATORS python-sdk" - fi - if echo "$CHANGED_FILES" | grep -q "^scripts/docs-generators/cuabot\.ts"; then - GENERATORS="$GENERATORS cuabot" - fi - - # Only runner.ts changes trigger all generators (it's the orchestration layer) - # config.json changes only affect whichever generator scripts were also changed - if echo "$CHANGED_FILES" | grep -qE "^scripts/docs-generators/runner\.ts$"; then - GENERATORS="all" - fi - - # Deduplicate - GENERATORS=$(echo "$GENERATORS" | tr ' ' '\n' | sort -u | tr '\n' ' ' | xargs) - - echo "generators=$GENERATORS" >> $GITHUB_OUTPUT - - - name: Build cua-driver (if needed) - if: contains(steps.changed.outputs.generators, 'cua-driver') || steps.changed.outputs.generators == 'all' - run: cargo build -p cua-driver --release - working-directory: libs/cua-driver/rust - - - name: Build Lume (if needed) - if: contains(steps.changed.outputs.generators, 'lume') || steps.changed.outputs.generators == 'all' - run: swift build -c release - working-directory: libs/lume + echo "generators=$GENERATORS" >> "$GITHUB_OUTPUT" - name: Run documentation check run: | @@ -134,14 +86,10 @@ jobs: exit 0 fi - if [ "$GENERATORS" = "all" ]; then - npx tsx scripts/docs-generators/runner.ts --check - else - for gen in $GENERATORS; do - echo "Checking generator: $gen" - npx tsx scripts/docs-generators/runner.ts --library "$gen" --check - done - fi + for gen in $GENERATORS; do + echo "Checking generator: $gen" + docs/node_modules/.bin/tsx scripts/docs-generators/runner.ts --library "$gen" --check + done working-directory: . - name: Show help if check failed @@ -156,18 +104,17 @@ jobs: echo "║ ║" echo "║ To regenerate all docs, run from the repository root: ║" echo "║ ║" - echo "║ npx tsx scripts/docs-generators/runner.ts ║" + echo "║ pnpm --dir docs docs:generate ║" echo "║ ║" echo "║ Or for a specific library: ║" echo "║ ║" - echo "║ npx tsx scripts/docs-generators/runner.ts --library lume ║" - echo "║ npx tsx scripts/docs-generators/runner.ts --library python-sdk ║" - echo "║ npx tsx scripts/docs-generators/runner.ts --library cuabot ║" + echo "║ pnpm --dir docs docs:generate:lume ║" + echo "║ pnpm --dir docs docs:generate:cua-driver ║" echo "║ ║" echo "║ For versioned docs and changelogs (after tagging a new release): ║" echo "║ ║" - echo "║ npx tsx scripts/docs-generators/generate-versioned-docs.ts ║" - echo "║ npx tsx scripts/docs-generators/generate-changelog.ts ║" + echo "║ pnpm --dir docs docs:generate:versions ║" + echo "║ pnpm --dir docs docs:generate:changelog ║" echo "║ ║" echo "║ Then commit the updated documentation files. ║" echo "║ ║" diff --git a/TESTING.md b/TESTING.md index 374f8fe03c..e58a513c96 100644 --- a/TESTING.md +++ b/TESTING.md @@ -112,15 +112,16 @@ Run from `docs`: ```bash pnpm install --frozen-lockfile -pnpm docs:check pnpm docs:check-hygiene pnpm docs:check-links pnpm build ``` The production build validates MDX compilation and static route generation. -The generator check prevents generated CLI and API references from drifting -from source. +Curated MDX changes do not need a product build. Generated reference changes +also run `pnpm docs:check:cua-driver` or `pnpm docs:check:lume` for their owning +component; `pnpm docs:check` is the explicit full audit. See +[`docs/README.md`](docs/README.md) for details. ## Before Opening a Pull Request diff --git a/docs/README.md b/docs/README.md index 70e45c05aa..df4b2723f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,13 +4,34 @@ Public documentation content and assets live in this repository. The production renderer, redirects, analytics, and site configuration live in `trycua/cloud`. This app is a local MDX preview for contributors to the public repository. -Run the local preview: +Install the docs dependencies and run the local preview from this directory: ```bash +pnpm install --frozen-lockfile pnpm dev ``` -Open http://localhost:8090 with your browser to see the result. +Open http://localhost:8090 with your browser to see the result. The docs app has +its own lockfile; installing dependencies at the repository root is not enough. + +## Validate a change + +Curated MDX pages use the content checks and production build: + +```bash +pnpm docs:check-hygiene +pnpm docs:check-links +pnpm build +``` + +For generated reference changes, also run the owning component check: + +```bash +pnpm docs:check:cua-driver +pnpm docs:check:lume +``` + +`pnpm docs:check` is the explicit full Cua Driver and Lume audit. ## Docs conventions diff --git a/docs/package.json b/docs/package.json index 75a4de380c..fa73693cf9 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,6 +9,8 @@ "postinstall": "fumadocs-mdx", "docs:generate": "tsx ../scripts/docs-generators/runner.ts", "docs:check": "tsx ../scripts/docs-generators/runner.ts --check", + "docs:check:cua-driver": "tsx ../scripts/docs-generators/runner.ts --library cua-driver --check", + "docs:check:lume": "tsx ../scripts/docs-generators/runner.ts --library lume --check", "docs:list": "tsx ../scripts/docs-generators/runner.ts --list", "docs:generate:lume": "tsx ../scripts/docs-generators/lume.ts", "docs:generate:cua-driver": "tsx ../scripts/docs-generators/cua-driver.ts", diff --git a/scripts/docs-generators/runner.ts b/scripts/docs-generators/runner.ts index c0f37d28e6..f6572e3377 100644 --- a/scripts/docs-generators/runner.ts +++ b/scripts/docs-generators/runner.ts @@ -7,11 +7,10 @@ * Reads config.json and runs appropriate generators based on what changed. * * Usage: - * npx tsx scripts/docs-generators/runner.ts # Generate all enabled docs - * npx tsx scripts/docs-generators/runner.ts --check # Check for drift (CI mode) - * npx tsx scripts/docs-generators/runner.ts --library lume # Generate specific library - * npx tsx scripts/docs-generators/runner.ts --list # List all configured generators - * npx tsx scripts/docs-generators/runner.ts --changed # Only run for changed files (CI) + * pnpm --dir docs docs:generate # Generate all enabled docs + * pnpm --dir docs docs:check # Check for drift (CI mode) + * pnpm --dir docs docs:check:lume # Check one library + * pnpm --dir docs docs:list # List configured generators */ import { execSync, spawnSync } from 'child_process'; @@ -54,6 +53,17 @@ interface Config { const ROOT_DIR = path.resolve(__dirname, '../..'); const CONFIG_PATH = path.join(__dirname, 'config.json'); +const DOCS_TSX_PATH = path.join( + ROOT_DIR, + 'docs', + 'node_modules', + '.bin', + process.platform === 'win32' ? 'tsx.cmd' : 'tsx' +); +const SHARED_GENERATOR_FILES = new Set([ + 'scripts/docs-generators/runner.ts', + 'scripts/docs-generators/config.json', +]); // ============================================================================ // Main @@ -66,12 +76,13 @@ async function main() { const checkOnly = args.includes('--check') || args.includes('--check-only'); const listOnly = args.includes('--list'); const changedOnly = args.includes('--changed'); + const changedFilesFileIndex = args.indexOf('--changed-files-file'); + const changedFilesFile = + changedFilesFileIndex !== -1 ? args[changedFilesFileIndex + 1] : undefined; + const testRouting = args.includes('--test-routing'); const libraryIndex = args.indexOf('--library'); const specificLibrary = libraryIndex !== -1 ? args[libraryIndex + 1] : null; - console.log('📚 Documentation Generator Runner'); - console.log('==================================\n'); - // Load config if (!fs.existsSync(CONFIG_PATH)) { console.error(`❌ Config file not found: ${CONFIG_PATH}`); @@ -80,6 +91,24 @@ async function main() { const config: Config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); + if (testRouting) { + testGeneratorRouting(config); + return; + } + + if (changedFilesFile) { + if (!fs.existsSync(changedFilesFile)) { + console.error(`Changed-files input not found: ${changedFilesFile}`); + process.exit(1); + } + const changedFiles = fs.readFileSync(changedFilesFile, 'utf-8').split(/\r?\n/).filter(Boolean); + console.log(selectGenerators(config, changedFiles).join(' ')); + return; + } + + console.log('📚 Documentation Generator Runner'); + console.log('==================================\n'); + // List mode if (listOnly) { listGenerators(config); @@ -145,7 +174,7 @@ async function main() { if (hasErrors) { console.error('❌ Some generators failed or detected drift.'); if (checkOnly) { - console.log("\n💡 Run 'npx tsx scripts/docs-generators/runner.ts' to update documentation"); + console.log("\n💡 Run 'pnpm --dir docs docs:generate' to update documentation"); } process.exit(1); } else { @@ -172,9 +201,10 @@ async function runGenerator( } try { - // Run the generator + // Use the docs app's lockfile-installed tsx; never fall back to npx downloads. + requireDocsTsx(); const args = checkOnly ? ['--check'] : []; - const result = spawnSync('npx', ['tsx', generatorPath, ...args], { + const result = spawnSync(DOCS_TSX_PATH, [generatorPath, ...args], { cwd: ROOT_DIR, stdio: 'inherit', encoding: 'utf-8', @@ -191,9 +221,125 @@ async function runGenerator( // Changed Files Detection (for CI) // ============================================================================ -function getChangedGenerators(config: Config): string[] { - const changedGenerators: string[] = []; +function requireDocsTsx(): string { + if (!fs.existsSync(DOCS_TSX_PATH)) { + throw new Error( + `Pinned tsx executable not found at ${DOCS_TSX_PATH}. Run pnpm --dir docs install --frozen-lockfile.` + ); + } + + const packagePath = path.join(ROOT_DIR, 'docs', 'node_modules', 'tsx', 'package.json'); + const lockfilePath = path.join(ROOT_DIR, 'docs', 'pnpm-lock.yaml'); + const installedVersion = JSON.parse(fs.readFileSync(packagePath, 'utf-8')).version as string; + const lockfile = fs.readFileSync(lockfilePath, 'utf-8'); + + if (!lockfile.includes(`tsx@${installedVersion}:`)) { + throw new Error(`Installed tsx ${installedVersion} is not pinned by docs/pnpm-lock.yaml`); + } + + return installedVersion; +} + +function globToRegExp(glob: string): RegExp { + let pattern = ''; + + for (let index = 0; index < glob.length; index += 1) { + const character = glob[index]; + + if (character === '*' && glob[index + 1] === '*') { + if (glob[index + 2] === '/') { + pattern += '(?:.*/)?'; + index += 2; + } else { + pattern += '.*'; + index += 1; + } + } else if (character === '*') { + pattern += '[^/]*'; + } else { + pattern += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + } + + return new RegExp(`^${pattern}$`); +} + +function selectGenerators(config: Config, changedFiles: readonly string[]): string[] { + const enabledGenerators = Object.entries(config.generators).filter(([_, cfg]) => cfg.enabled); + const normalizedFiles = changedFiles.map((file) => file.replace(/\\/g, '/')); + + if (normalizedFiles.some((file) => SHARED_GENERATOR_FILES.has(file))) { + return enabledGenerators.map(([key]) => key); + } + + return enabledGenerators.flatMap(([key, generator]) => { + const ownedFiles = new Set([ + generator.generatorScript, + ...generator.outputs.map((output) => + path.posix.join(generator.docsOutputPath, output.outputFile) + ), + ]); + const watchPatterns = generator.watchPaths.map(globToRegExp); + const selected = normalizedFiles.some( + (file) => + file.startsWith(`${generator.sourcePath}/`) || + ownedFiles.has(file) || + watchPatterns.some((pattern) => pattern.test(file)) + ); + + return selected ? [key] : []; + }); +} + +function assertSelection( + config: Config, + changedFiles: readonly string[], + expected: readonly string[] +): void { + const actual = selectGenerators(config, changedFiles); + if (actual.join('\n') !== expected.join('\n')) { + throw new Error( + `Routing assertion failed for ${changedFiles.join(', ')}: expected ${expected.join(', ')}, got ${actual.join(', ')}` + ); + } +} +function testGeneratorRouting(config: Config): void { + const tsxVersion = requireDocsTsx(); + + assertSelection( + config, + [ + 'docs/content/docs/use-cua-with/hermes.mdx', + 'docs/content/docs/reference/cua-driver/macos-permissions.mdx', + ], + [] + ); + assertSelection( + config, + [ + 'libs/cua-driver/rust/crates/cua-driver/src/main.rs', + 'docs/content/docs/reference/cua-driver/cli-reference.mdx', + 'scripts/docs-generators/cua-driver.ts', + ], + ['cua-driver'] + ); + assertSelection( + config, + [ + 'libs/lume/src/Commands/List.swift', + 'docs/content/docs/reference/lume/http-api.mdx', + 'scripts/docs-generators/lume.ts', + ], + ['lume'] + ); + assertSelection(config, ['scripts/docs-generators/runner.ts'], ['cua-driver', 'lume']); + assertSelection(config, ['scripts/docs-generators/config.json'], ['cua-driver', 'lume']); + + console.log(`Generator routing assertions passed with pinned tsx ${tsxVersion}`); +} + +function getChangedGenerators(config: Config): string[] { try { // Get changed files from git // This works for both PRs (comparing to base) and pushes @@ -206,31 +352,15 @@ function getChangedGenerators(config: Config): string[] { console.log(`📝 Changed files: ${changedFiles.length}`); - for (const [key, generator] of Object.entries(config.generators)) { - if (!generator.enabled) continue; - - // Check if any watch path matches changed files - const watchPatterns = generator.watchPaths.map( - (p) => new RegExp(p.replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*').replace(/\//g, '\\/')) - ); - - const hasChanges = changedFiles.some((file) => - watchPatterns.some((pattern) => pattern.test(file)) - ); - - if (hasChanges) { - changedGenerators.push(key); - console.log(` 📌 ${key}: changes detected`); - } - } + const changedGenerators = selectGenerators(config, changedFiles); + for (const key of changedGenerators) console.log(` 📌 ${key}: changes detected`); + return changedGenerators; } catch (error) { console.warn('⚠️ Could not detect changed files, running all generators'); return Object.entries(config.generators) .filter(([_, cfg]) => cfg.enabled) .map(([key, _]) => key); } - - return changedGenerators; } // ============================================================================ From ee09e869727ce1f80793b0dd830a29139cb6fffa Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 20 Aug 2026 11:23:36 -0700 Subject: [PATCH 086/117] fix(cua-driver): reset stale local TCC rows after ad-hoc rebuilds (#2747) Co-authored-by: trycua-release[bot] --- .../tests/test_install_local_signing.py | 58 +++++++++++++++++++ .../cua-driver/scripts/_install-local-rust.sh | 15 +++++ libs/cua-driver/scripts/_local-signing.sh | 47 +++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/libs/cua-driver/python/tests/test_install_local_signing.py b/libs/cua-driver/python/tests/test_install_local_signing.py index bf38b0850d..b044866f5d 100644 --- a/libs/cua-driver/python/tests/test_install_local_signing.py +++ b/libs/cua-driver/python/tests/test_install_local_signing.py @@ -94,6 +94,62 @@ def test_certificate_requirement_is_classified_as_stable() -> None: assert "stable local identity" in result.stdout +def test_changed_ad_hoc_requirement_resets_only_local_driver_services() -> None: + result = run_signing_policy( + r""" + RED= YELLOW= NORMAL= + calls="" + tccutil() { calls="${calls}${1}:${2}:${3}"$'\n'; } + reset_local_tcc_after_ad_hoc_change \ + 'cdhash H"OLD"' 'cdhash H"NEW"' + printf '%s' "$calls" + """ + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == ( + "reset:Accessibility:com.trycua.driver.local\n" + "reset:ScreenCapture:com.trycua.driver.local\n" + ) + assert "cleared stale Accessibility and Screen Recording rows" in result.stderr + assert "cua-driver-local permissions grant" in result.stderr + + +def test_unchanged_or_certificate_requirements_preserve_tcc_rows() -> None: + result = run_signing_policy( + r""" + RED= YELLOW= NORMAL= + tccutil() { echo unexpected >&2; return 99; } + reset_local_tcc_after_ad_hoc_change '' 'cdhash H"FIRST"' + reset_local_tcc_after_ad_hoc_change \ + 'cdhash H"SAME"' 'cdhash H"SAME"' + reset_local_tcc_after_ad_hoc_change \ + 'certificate leaf = H"OLD"' 'certificate leaf = H"NEW"' + """ + ) + + assert result.returncode == 0, result.stderr + assert "unexpected" not in result.stderr + + +def test_ad_hoc_tcc_reset_failure_is_actionable_and_fails_closed() -> None: + result = run_signing_policy( + r""" + RED= YELLOW= NORMAL= + tccutil() { [ "$2" != ScreenCapture ]; } + if reset_local_tcc_after_ad_hoc_change \ + 'cdhash H"OLD"' 'cdhash H"NEW"'; then + exit 90 + fi + """ + ) + + assert result.returncode == 0, result.stderr + assert "could not reset these TCC services" in result.stderr + assert "tccutil reset Accessibility com.trycua.driver.local" in result.stderr + assert "tccutil reset ScreenCapture com.trycua.driver.local" in result.stderr + + def test_installer_verifies_the_copied_designated_requirement() -> None: script = (SCRIPTS_DIR / "_install-local-rust.sh").read_text() @@ -101,6 +157,8 @@ def test_installer_verifies_the_copied_designated_requirement() -> None: assert '[ "$INSTALLED_REQUIREMENT" = "$STAGED_REQUIREMENT" ]' in script assert "verified installed designated requirement: certificate-backed" in script assert "verified installed designated requirement: ad-hoc cdhash" in script + assert 'PREVIOUS_REQUIREMENT="$(designated_requirement "$APP_DEST")"' in script + assert "reset_local_tcc_after_ad_hoc_change" in script def test_local_installer_uses_a_separate_macos_identity() -> None: diff --git a/libs/cua-driver/scripts/_install-local-rust.sh b/libs/cua-driver/scripts/_install-local-rust.sh index 7f9dd6fdfd..f64c3af753 100755 --- a/libs/cua-driver/scripts/_install-local-rust.sh +++ b/libs/cua-driver/scripts/_install-local-rust.sh @@ -391,6 +391,10 @@ if [ "$OS" = "Darwin" ]; then chmod +x "$APP_STAGE/Contents/MacOS/cua-driver-local" chmod +x "$APP_STAGE/Contents/MacOS/cua-cursor-theme" rm -f "$APP_STAGE/Contents/MacOS/.gitkeep" + PREVIOUS_REQUIREMENT="" + if [ -d "$APP_DEST" ] && command -v codesign >/dev/null 2>&1; then + PREVIOUS_REQUIREMENT="$(designated_requirement "$APP_DEST")" + fi # Stamp the local build version so the bundle reports something sane. if command -v plutil >/dev/null 2>&1; then plutil -replace CFBundleShortVersionString -string "$VERSION_TAG" \ @@ -520,6 +524,17 @@ for _daemon_bin in "$INSTALLED_BIN" "$BIN_TARGET"; do done unset _daemon_bin +# A changed ad-hoc cdhash leaves the old csreq attached to this bundle's TCC +# rows. Once the new bundle is registered and old daemons are stopped, reset +# only its Accessibility and ScreenCapture rows so `permissions grant` can +# create entries for the new identity. +if [ "$OS" = "Darwin" ]; then + if ! reset_local_tcc_after_ad_hoc_change \ + "$PREVIOUS_REQUIREMENT" "$INSTALLED_REQUIREMENT"; then + exit 1 + fi +fi + # Agent skill pack symlinks: NOT auto-created. Run # `cua-driver skills install --local` to symlink agent dirs to the # staged copy at $VERSIONED_DIR/Skills/cua-driver above. diff --git a/libs/cua-driver/scripts/_local-signing.sh b/libs/cua-driver/scripts/_local-signing.sh index 0ad1a31527..0c26247298 100644 --- a/libs/cua-driver/scripts/_local-signing.sh +++ b/libs/cua-driver/scripts/_local-signing.sh @@ -125,6 +125,53 @@ classify_designated_requirement() { esac } +# An ad-hoc signature's designated requirement is its cdhash. Replacing the +# bundle with a different ad-hoc build leaves TCC rows carrying the old csreq; +# toggling the visible System Settings entry does not reliably rewrite it. +ad_hoc_requirement_changed() { + local previous_requirement="$1" + local installed_requirement="$2" + + [ -n "$previous_requirement" ] \ + && [ -n "$installed_requirement" ] \ + && [ "$previous_requirement" != "$installed_requirement" ] \ + && [ "$(classify_designated_requirement "$previous_requirement")" = "ad-hoc" ] \ + && [ "$(classify_designated_requirement "$installed_requirement")" = "ad-hoc" ] +} + +# Reset only the two TCC services used by Cua Driver Local, and only when an +# actual ad-hoc cdhash transition was observed. The caller must register the +# newly installed bundle with LaunchServices before invoking this function. +reset_local_tcc_after_ad_hoc_change() { + local previous_requirement="$1" + local installed_requirement="$2" + local bundle_id="com.trycua.driver.local" + local service failed_services="" + + ad_hoc_requirement_changed "$previous_requirement" "$installed_requirement" || return 0 + + if ! command -v tccutil >/dev/null 2>&1; then + echo "${RED}Error: tccutil is required to clear stale local-app permission rows after an ad-hoc cdhash change.${NORMAL}" >&2 + return 1 + fi + + for service in Accessibility ScreenCapture; do + if ! tccutil reset "$service" "$bundle_id" >/dev/null 2>&1; then + failed_services="$failed_services $service" + fi + done + if [ -n "$failed_services" ]; then + echo "${RED}Error: could not reset these TCC services for $bundle_id:$failed_services.${NORMAL}" >&2 + echo "The new app is installed, but its stale permission rows may remain. Run:" >&2 + echo " tccutil reset Accessibility $bundle_id" >&2 + echo " tccutil reset ScreenCapture $bundle_id" >&2 + return 1 + fi + + echo "${YELLOW}The ad-hoc cdhash changed; cleared stale Accessibility and Screen Recording rows for $bundle_id.${NORMAL}" >&2 + echo "Re-grant them to the new app with: cua-driver-local permissions grant" >&2 +} + # Signs a staged local app without touching the live installation. Strict mode # refuses the ad-hoc path; non-strict mode keeps it available for casual local # development but makes the resulting TCC reset impossible to miss. From 3ae080d366b0fc514ef03b8f5136f29e7477c2ae Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:54:47 +0800 Subject: [PATCH 087/117] feat(cua-driver): configure embedded daemon overlay (#3280) * feat(cua-driver): configure embedded daemon overlay * test(cua-driver): preserve embedded worker defaults * test(cua-driver): cover generated overlay options Co-authored-by: Francesco Bonacci --------- Co-authored-by: Francesco Bonacci --- .../python/src/cua_driver/_native.py | 10 ++++++-- .../python/tests/test_uniffi_loader.py | 25 +++++++++++++++++++ .../crates/cua-driver-sdk/src/embedded.rs | 25 +++++++++++++++++++ .../cua-driver/tests/private_worker_test.rs | 1 + .../typescript/src/native/cua_driver_sdk.ts | 11 +++++--- .../typescript/test/embedded.test.mjs | 21 ++++++++++++++++ 6 files changed, 88 insertions(+), 5 deletions(-) diff --git a/libs/cua-driver/python/src/cua_driver/_native.py b/libs/cua-driver/python/src/cua_driver/_native.py index ee00789bdc..e04b00ded2 100644 --- a/libs/cua-driver/python/src/cua_driver/_native.py +++ b/libs/cua-driver/python/src/cua_driver/_native.py @@ -2993,7 +2993,7 @@ def read(cls, buf): @dataclass class EmbeddedDriverHostOptions: - def __init__(self, *, binary_path:str, host_bundle_id:str, socket_path:typing.Optional[str], startup_timeout_ms:typing.Optional[int], shutdown_timeout_ms:typing.Optional[int], permission_mode:typing.Optional[EmbeddedPermissionMode], capability_manifest_path:typing.Optional[str] = _DEFAULT, approve_capability_manifest:bool = False, session_policy_path:typing.Optional[str], approve_session_policy:bool, dangerously_bypass_approvals:bool, environment:typing.List[EmbeddedEnvironmentVariable], inherit_stderr:bool): + def __init__(self, *, binary_path:str, host_bundle_id:str, socket_path:typing.Optional[str], startup_timeout_ms:typing.Optional[int], shutdown_timeout_ms:typing.Optional[int], permission_mode:typing.Optional[EmbeddedPermissionMode], capability_manifest_path:typing.Optional[str] = _DEFAULT, approve_capability_manifest:bool = False, session_policy_path:typing.Optional[str], approve_session_policy:bool, dangerously_bypass_approvals:bool, environment:typing.List[EmbeddedEnvironmentVariable], inherit_stderr:bool, no_overlay:bool = False): self.binary_path = binary_path self.host_bundle_id = host_bundle_id self.socket_path = socket_path @@ -3010,12 +3010,13 @@ def __init__(self, *, binary_path:str, host_bundle_id:str, socket_path:typing.Op self.dangerously_bypass_approvals = dangerously_bypass_approvals self.environment = environment self.inherit_stderr = inherit_stderr + self.no_overlay = no_overlay def __str__(self): - return "EmbeddedDriverHostOptions(binary_path={}, host_bundle_id={}, socket_path={}, startup_timeout_ms={}, shutdown_timeout_ms={}, permission_mode={}, capability_manifest_path={}, approve_capability_manifest={}, session_policy_path={}, approve_session_policy={}, dangerously_bypass_approvals={}, environment={}, inherit_stderr={})".format(self.binary_path, self.host_bundle_id, self.socket_path, self.startup_timeout_ms, self.shutdown_timeout_ms, self.permission_mode, self.capability_manifest_path, self.approve_capability_manifest, self.session_policy_path, self.approve_session_policy, self.dangerously_bypass_approvals, self.environment, self.inherit_stderr) + return "EmbeddedDriverHostOptions(binary_path={}, host_bundle_id={}, socket_path={}, startup_timeout_ms={}, shutdown_timeout_ms={}, permission_mode={}, capability_manifest_path={}, approve_capability_manifest={}, session_policy_path={}, approve_session_policy={}, dangerously_bypass_approvals={}, environment={}, inherit_stderr={}, no_overlay={})".format(self.binary_path, self.host_bundle_id, self.socket_path, self.startup_timeout_ms, self.shutdown_timeout_ms, self.permission_mode, self.capability_manifest_path, self.approve_capability_manifest, self.session_policy_path, self.approve_session_policy, self.dangerously_bypass_approvals, self.environment, self.inherit_stderr, self.no_overlay) def __eq__(self, other): if self.binary_path != other.binary_path: return False @@ -3043,6 +3044,8 @@ def __eq__(self, other): return False if self.inherit_stderr != other.inherit_stderr: return False + if self.no_overlay != other.no_overlay: + return False return True class _UniffiFfiConverterTypeEmbeddedDriverHostOptions(_UniffiConverterRustBuffer): @@ -3062,6 +3065,7 @@ def read(buf): dangerously_bypass_approvals=_UniffiFfiConverterBoolean.read(buf), environment=_UniffiFfiConverterSequenceTypeEmbeddedEnvironmentVariable.read(buf), inherit_stderr=_UniffiFfiConverterBoolean.read(buf), + no_overlay=_UniffiFfiConverterBoolean.read(buf), ) @staticmethod @@ -3079,6 +3083,7 @@ def check_lower(value): _UniffiFfiConverterBoolean.check_lower(value.dangerously_bypass_approvals) _UniffiFfiConverterSequenceTypeEmbeddedEnvironmentVariable.check_lower(value.environment) _UniffiFfiConverterBoolean.check_lower(value.inherit_stderr) + _UniffiFfiConverterBoolean.check_lower(value.no_overlay) @staticmethod def write(value, buf): @@ -3095,6 +3100,7 @@ def write(value, buf): _UniffiFfiConverterBoolean.write(value.dangerously_bypass_approvals, buf) _UniffiFfiConverterSequenceTypeEmbeddedEnvironmentVariable.write(value.environment, buf) _UniffiFfiConverterBoolean.write(value.inherit_stderr, buf) + _UniffiFfiConverterBoolean.write(value.no_overlay, buf) @dataclass class ImageContent: diff --git a/libs/cua-driver/python/tests/test_uniffi_loader.py b/libs/cua-driver/python/tests/test_uniffi_loader.py index 6441166b5f..cf35316c11 100644 --- a/libs/cua-driver/python/tests/test_uniffi_loader.py +++ b/libs/cua-driver/python/tests/test_uniffi_loader.py @@ -23,6 +23,31 @@ def _library_name() -> str: LIBRARY = Path(__file__).parents[1] / "src" / "cua_driver" / _library_name() +@unittest.skipUnless(LIBRARY.exists(), "host-native UniFFI library is not staged") +class GeneratedOptionsTests(unittest.TestCase): + def test_embedded_overlay_option_defaults_false_and_accepts_true(self) -> None: + from cua_driver import EmbeddedDriverHostOptions + + required = { + "binary_path": "/example/cua-driver", + "host_bundle_id": "com.example.host", + "socket_path": None, + "startup_timeout_ms": None, + "shutdown_timeout_ms": None, + "permission_mode": None, + "session_policy_path": None, + "approve_session_policy": False, + "dangerously_bypass_approvals": False, + "environment": [], + "inherit_stderr": False, + } + + self.assertFalse(EmbeddedDriverHostOptions(**required).no_overlay) + self.assertTrue( + EmbeddedDriverHostOptions(**required, no_overlay=True).no_overlay + ) + + @unittest.skipUnless(LIBRARY.exists(), "host-native UniFFI library is not staged") @unittest.skipIf(os.name == "nt", "Unix socket fixture") class SdkLoaderTests(unittest.TestCase): diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs index c205a53680..3445e64f34 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs @@ -53,6 +53,8 @@ pub struct EmbeddedDriverHostOptions { pub dangerously_bypass_approvals: bool, pub environment: Vec, pub inherit_stderr: bool, + #[uniffi(default = false)] + pub no_overlay: bool, } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] @@ -121,6 +123,7 @@ struct ValidatedOptions { dangerously_bypass_approvals: bool, environment: Vec, inherit_stderr: bool, + no_overlay: bool, } #[cfg(unix)] @@ -263,6 +266,7 @@ impl EmbeddedCuaDriverHost { dangerously_bypass_approvals: false, environment: Vec::new(), inherit_stderr: true, + no_overlay: false, }) } @@ -648,6 +652,9 @@ impl EmbeddedCuaDriverHost { if self.options.dangerously_bypass_approvals { args.push("--dangerously-bypass-approvals".into()); } + if self.options.no_overlay { + args.push("--no-overlay".into()); + } args } @@ -815,6 +822,7 @@ fn validate_options( dangerously_bypass_approvals: options.dangerously_bypass_approvals, environment: options.environment, inherit_stderr: options.inherit_stderr, + no_overlay: options.no_overlay, }) } @@ -1148,6 +1156,7 @@ mod tests { dangerously_bypass_approvals: false, environment: Vec::new(), inherit_stderr: false, + no_overlay: false, } } @@ -1180,6 +1189,22 @@ mod tests { assert!(validate_options(unrestricted_manifest).is_ok()); } + #[test] + fn no_overlay_is_opt_in_on_the_owned_daemon() { + let default_host = + EmbeddedCuaDriverHost::with_options(options(EmbeddedPermissionMode::Standard)).unwrap(); + assert!(!default_host + .serve_args("/tmp/cua-default.sock") + .contains(&"--no-overlay".into())); + + let mut configured = options(EmbeddedPermissionMode::Standard); + configured.no_overlay = true; + let configured_host = EmbeddedCuaDriverHost::with_options(configured).unwrap(); + assert!(configured_host + .serve_args("/tmp/cua-no-overlay.sock") + .contains(&"--no-overlay".into())); + } + #[test] fn capability_manifest_aliases_must_not_conflict() { let mut options = options(EmbeddedPermissionMode::Standard); diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs index 14029e08f1..b61131bee7 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/private_worker_test.rs @@ -252,6 +252,7 @@ async fn embedded_service_binds_authority_to_the_original_host_connection() { dangerously_bypass_approvals: false, environment: Vec::::new(), inherit_stderr: true, + no_overlay: false, }) .unwrap(); let connection = host.clone().start().await.unwrap(); diff --git a/libs/cua-driver/typescript/src/native/cua_driver_sdk.ts b/libs/cua-driver/typescript/src/native/cua_driver_sdk.ts index 53e0bd65c8..1a41a0bf4b 100644 --- a/libs/cua-driver/typescript/src/native/cua_driver_sdk.ts +++ b/libs/cua-driver/typescript/src/native/cua_driver_sdk.ts @@ -917,7 +917,8 @@ export type EmbeddedDriverHostOptions = { approveSessionPolicy: boolean, dangerouslyBypassApprovals: boolean, environment: Array, - inheritStderr: boolean + inheritStderr: boolean, + noOverlay: boolean } /** @@ -927,6 +928,7 @@ export const EmbeddedDriverHostOptions = (() => { const defaults = () => ({ capabilityManifestPath: undefined, approveCapabilityManifest: false, + noOverlay: false }); const create = (() => { return uniffiCreateRecord>(defaults); @@ -955,7 +957,8 @@ const FfiConverterTypeEmbeddedDriverHostOptions = (() => { approveSessionPolicy: FfiConverterBool.read(from), dangerouslyBypassApprovals: FfiConverterBool.read(from), environment: FfiConverterSequenceTypeEmbeddedEnvironmentVariable.read(from), - inheritStderr: FfiConverterBool.read(from) + inheritStderr: FfiConverterBool.read(from), + noOverlay: FfiConverterBool.read(from) }; } write(value: TypeName, into: RustBuffer): void { @@ -972,6 +975,7 @@ const FfiConverterTypeEmbeddedDriverHostOptions = (() => { FfiConverterBool.write(value.dangerouslyBypassApprovals, into); FfiConverterSequenceTypeEmbeddedEnvironmentVariable.write(value.environment, into); FfiConverterBool.write(value.inheritStderr, into); + FfiConverterBool.write(value.noOverlay, into); } allocationSize(value: TypeName): number { return FfiConverterString.allocationSize(value.binaryPath) + @@ -986,7 +990,8 @@ const FfiConverterTypeEmbeddedDriverHostOptions = (() => { FfiConverterBool.allocationSize(value.approveSessionPolicy) + FfiConverterBool.allocationSize(value.dangerouslyBypassApprovals) + FfiConverterSequenceTypeEmbeddedEnvironmentVariable.allocationSize(value.environment) + - FfiConverterBool.allocationSize(value.inheritStderr); + FfiConverterBool.allocationSize(value.inheritStderr) + + FfiConverterBool.allocationSize(value.noOverlay); } }; diff --git a/libs/cua-driver/typescript/test/embedded.test.mjs b/libs/cua-driver/typescript/test/embedded.test.mjs index d52fc10811..67473d8b1d 100644 --- a/libs/cua-driver/typescript/test/embedded.test.mjs +++ b/libs/cua-driver/typescript/test/embedded.test.mjs @@ -34,6 +34,27 @@ test( assert.equal(embedded.EmbeddedCuaDriverHost, root.EmbeddedCuaDriverHost) assert.equal(embedded.EmbeddedDriverHostOptions, root.EmbeddedDriverHostOptions) assert.equal(embedded.EmbeddedPermissionMode, root.EmbeddedPermissionMode) + + const requiredOptions = { + binaryPath: "/example/cua-driver", + hostBundleId: "com.example.host", + approveSessionPolicy: false, + dangerouslyBypassApprovals: false, + environment: [], + inheritStderr: false, + } + assert.equal( + embedded.EmbeddedDriverHostOptions.new(requiredOptions).noOverlay, + false, + ) + assert.equal( + embedded.EmbeddedDriverHostOptions.new({ + ...requiredOptions, + noOverlay: true, + }).noOverlay, + true, + ) + assert.throws( () => new embedded.EmbeddedCuaDriverHost("", "com.example.host"), error => error?.inner?.reason === "binary_path must not be empty", From 9a61050e3474fc9488d7adc85184299f02514d0e Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:56:47 +0800 Subject: [PATCH 088/117] fix(cua-driver): honor HERMES_HOME for skill links (#3291) * fix(cua-driver): honor HERMES_HOME for skill links * test(cua-driver): cover Hermes skill path fallbacks --------- Co-authored-by: Francesco Bonacci --- .../rust/crates/cua-driver/src/skills.rs | 158 ++++++++++++++++-- .../tests/hermes_skills_cli_test.rs | 59 +++++++ 2 files changed, 204 insertions(+), 13 deletions(-) create mode 100644 libs/cua-driver/rust/crates/cua-driver/tests/hermes_skills_cli_test.rs diff --git a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs index 8fd29e23c0..e2f8d701e8 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs @@ -49,9 +49,10 @@ //! - Antigravity: `~/.gemini/skills/` — shared between Antigravity CLI //! (`agy`) and Antigravity IDE; same dir Google Gemini CLI used before the //! May-2026 transition, so existing installs migrate forward unchanged. -//! - Hermes: `~/.hermes/skills/` — NousResearch/hermes-agent. The user-level -//! skill space Hermes resolves at agent load time (separate from the -//! repo-bundled `hermes-agent/skills/` tree, which is read-only and +//! - Hermes: `$HERMES_HOME/skills/` when set, otherwise `~/.hermes/skills/` on +//! macOS/Linux or `%LOCALAPPDATA%\hermes\skills\` on Windows. This is the +//! user-level skill space Hermes resolves at agent load time (separate from +//! the repo-bundled `hermes-agent/skills/` tree, which is read-only and //! version-controlled). Hermes' own `computer-use` skill teaches its wrapper //! vocabulary; the cua-driver pack provides the platform deep dives. //! @@ -181,6 +182,10 @@ struct Agent { enum AgentParent { /// `/`. Home(&'static str), + /// Hermes' effective user skill directory. Unlike the other agents, + /// Hermes supports an explicit `HERMES_HOME` and uses a native-Windows + /// default outside `USERPROFILE`. + Hermes, /// `/` (Windows roaming app config). /// /// `#[allow(dead_code)]`: constructed only inside `#[cfg(windows)]` @@ -227,20 +232,73 @@ const AGENTS: &[Agent] = &[ label: "Antigravity", parent: AgentParent::Home(".gemini/skills"), }, - // Hermes (NousResearch/hermes-agent) resolves user skills from - // `~/.hermes/skills/` at agent load time — the same directory its - // `/skills install …` slash command and `hermes skills install` - // CLI write to. Hermes' bundled `skills/computer-use/SKILL.md` - // teaches the Hermes `computer_use` action vocabulary; the - // cua-driver pack symlinked here adds the platform-specific deep - // dives (MACOS.md / WINDOWS.md / LINUX.md / RECORDING.md / - // BROWSER.md) that Hermes deliberately doesn't clone. + // Hermes (NousResearch/hermes-agent) resolves user skills from its + // effective home at agent load time. `HERMES_HOME` can select a custom + // home or profile; otherwise Hermes uses `~/.hermes` on macOS/Linux and + // `%LOCALAPPDATA%\hermes` on native Windows. Hermes' bundled + // `skills/computer-use/SKILL.md` teaches the wrapper vocabulary; this + // pack adds the platform-specific deep dives. Agent { label: "Hermes", - parent: AgentParent::Home(".hermes/skills"), + parent: AgentParent::Hermes, }, ]; +fn hermes_skills_dir_from_env() -> Result { + resolve_hermes_skills_dir( + std::env::var("HERMES_HOME").ok().as_deref(), + default_hermes_home, + ) +} + +fn resolve_hermes_skills_dir( + override_home: Option<&str>, + default_home: impl FnOnce() -> Result, +) -> Result { + let home = match override_home.map(str::trim).filter(|path| !path.is_empty()) { + Some(path) => PathBuf::from(path), + None => default_home()?, + }; + Ok(home.join("skills")) +} + +#[cfg(not(windows))] +fn default_hermes_home() -> Result { + unix_hermes_home(std::env::var("HOME").ok().as_deref()) +} + +#[cfg(any(not(windows), test))] +fn unix_hermes_home(home: Option<&str>) -> Result { + let home = home + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| anyhow!("HOME not set"))?; + Ok(PathBuf::from(home).join(".hermes")) +} + +#[cfg(windows)] +fn default_hermes_home() -> Result { + windows_hermes_home( + std::env::var("LOCALAPPDATA").ok().as_deref(), + std::env::var("USERPROFILE").ok().as_deref(), + ) +} + +#[cfg(any(windows, test))] +fn windows_hermes_home(local_appdata: Option<&str>, userprofile: Option<&str>) -> Result { + if let Some(path) = local_appdata.map(str::trim).filter(|path| !path.is_empty()) { + return Ok(PathBuf::from(path).join("hermes")); + } + let profile = userprofile + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| anyhow!("LOCALAPPDATA and USERPROFILE not set"))?; + Ok(PathBuf::from(profile) + .join("AppData") + .join("Local") + .join("hermes")) +} + impl Agent { fn parent_path(&self) -> Result { match self.parent { @@ -252,6 +310,7 @@ impl Agent { let base = std::env::var("HOME").map_err(|_| anyhow!("HOME not set"))?; Ok(PathBuf::from(base).join(seg.replace('/', std::path::MAIN_SEPARATOR_STR))) } + AgentParent::Hermes => hermes_skills_dir_from_env(), AgentParent::AppData(seg) => { #[cfg(windows)] let base = std::env::var("APPDATA").map_err(|_| anyhow!("APPDATA not set"))?; @@ -808,7 +867,10 @@ fn print_path() -> Result<()> { #[cfg(test)] mod tests { - use super::{extract_tar_gz, skill_release_url, AgentParent, AGENTS, SKILL_FILES}; + use super::{ + extract_tar_gz, resolve_hermes_skills_dir, skill_release_url, unix_hermes_home, + windows_hermes_home, AgentParent, AGENTS, SKILL_FILES, + }; use std::path::PathBuf; use tempfile::tempdir; @@ -825,6 +887,76 @@ mod tests { )); } + #[test] + fn hermes_target_prefers_hermes_home() { + let path = resolve_hermes_skills_dir(Some("/profiles/work"), || { + panic!("the fallback must not be read") + }) + .unwrap(); + assert_eq!(path, PathBuf::from("/profiles/work/skills")); + } + + #[test] + fn hermes_target_ignores_empty_or_whitespace_override() { + for override_home in [None, Some(""), Some(" \t ")] { + let path = + resolve_hermes_skills_dir(override_home, || Ok(PathBuf::from("/fallback/hermes"))) + .unwrap(); + assert_eq!(path, PathBuf::from("/fallback/hermes/skills")); + } + } + + #[test] + fn hermes_target_uses_unix_default_home() { + assert_eq!( + unix_hermes_home(Some("/home/test")).unwrap(), + PathBuf::from("/home/test/.hermes") + ); + } + + #[test] + fn hermes_target_uses_native_windows_local_appdata() { + let home = windows_hermes_home(Some("C:/Users/test/AppData/Local"), None).unwrap(); + assert_eq!(home, PathBuf::from("C:/Users/test/AppData/Local/hermes")); + } + + #[test] + fn hermes_target_uses_windows_userprofile_fallback() { + let expected = PathBuf::from("C:/Users/test") + .join("AppData") + .join("Local") + .join("hermes"); + for local_appdata in [None, Some(""), Some(" \t ")] { + assert_eq!( + windows_hermes_home(local_appdata, Some("C:/Users/test")).unwrap(), + expected + ); + } + } + + #[test] + fn hermes_defaults_report_missing_required_environment() { + for home in [None, Some(""), Some(" \t ")] { + assert_eq!( + unix_hermes_home(home).unwrap_err().to_string(), + "HOME not set" + ); + } + for (local_appdata, userprofile) in [ + (None, None), + (Some(""), None), + (None, Some(" \t ")), + (Some(" "), Some("")), + ] { + assert_eq!( + windows_hermes_home(local_appdata, userprofile) + .unwrap_err() + .to_string(), + "LOCALAPPDATA and USERPROFILE not set" + ); + } + } + #[test] fn stable_skill_pack_uses_the_stable_release_tag() { assert_eq!( diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/hermes_skills_cli_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/hermes_skills_cli_test.rs new file mode 100644 index 0000000000..546077c1e5 --- /dev/null +++ b/libs/cua-driver/rust/crates/cua-driver/tests/hermes_skills_cli_test.rs @@ -0,0 +1,59 @@ +#[cfg(unix)] +mod unix { + use std::fs; + use std::os::unix::fs::symlink; + use std::process::Command; + + use tempfile::tempdir; + + #[test] + fn status_and_uninstall_use_the_selected_hermes_home() { + let root = tempdir().expect("temporary test root"); + let driver_home = root.path().join("driver"); + let hermes_home = root.path().join("hermes-profile"); + let hermes_skills = hermes_home.join("skills"); + let local_skill = driver_home.join("skills").join("cua-driver"); + let hermes_link = hermes_skills.join("cua-driver"); + + fs::create_dir_all(&hermes_skills).expect("create Hermes skills directory"); + fs::create_dir_all(&local_skill).expect("create local skill directory"); + fs::write(local_skill.join("SKILL.md"), "# test\n").expect("write test skill"); + symlink(&local_skill, &hermes_link).expect("link test skill into Hermes"); + + let status = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args(["skills", "status"]) + .env("CUA_DRIVER_RS_HOME", &driver_home) + .env("HERMES_HOME", &hermes_home) + .env("HOME", root.path().join("unused-home")) + .output() + .expect("run skills status"); + assert!(status.status.success(), "{status:?}"); + let stdout = String::from_utf8(status.stdout).expect("UTF-8 status output"); + assert!( + stdout.contains(&hermes_link.display().to_string()), + "{stdout}" + ); + assert!(stdout.contains("Hermes"), "{stdout}"); + assert!(stdout.contains("linked"), "{stdout}"); + + let uninstall = Command::new(env!("CARGO_BIN_EXE_cua-driver")) + .args(["skills", "uninstall"]) + .env("CUA_DRIVER_RS_HOME", &driver_home) + .env("HERMES_HOME", &hermes_home) + .env("HOME", root.path().join("unused-home")) + .output() + .expect("run skills uninstall"); + assert!(uninstall.status.success(), "{uninstall:?}"); + let stdout = String::from_utf8(uninstall.stdout).expect("UTF-8 uninstall output"); + assert!( + stdout.contains(&hermes_link.display().to_string()), + "{stdout}" + ); + assert!(!hermes_link.exists()); + assert!(hermes_link.symlink_metadata().is_err()); + assert!( + local_skill.exists(), + "uninstall without --all keeps local skill" + ); + } +} From d138baa96a1b4a379dfb498c17101f20cfcf7aef Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Fri, 21 Aug 2026 07:16:30 +0800 Subject: [PATCH 089/117] fix(cua-driver): normalize legacy dispatch before authorization (#3037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cua-driver): accept legacy dispatch alias for delivery_mode Clients built against the 0.5.x tool surface send the delivery parameter as `dispatch` (background/foreground/auto); it was later renamed to `delivery_mode` (auto removed). The arg parsers ignore unknown JSON fields, so a legacy client's explicit dispatch:"foreground" silently degraded to the Background default on every input tool. That interacts badly with the escalation contract: the driver refuses an impossible background action with background_unavailable + escalation.recommended:"foreground", the legacy client re-issues the call with dispatch:"foreground" exactly as advised, and receives the same refusal — an escalation loop with no exit. Observed in the wild with an application that bundles and pins driver 0.5.2. DeliveryMode::from_args now falls back to the legacy `dispatch` field when `delivery_mode` is absent. One parse point covers every Windows input tool (click, double/right click, drag, press_key, hotkey, set_value, scroll). delivery_mode wins when both are present; the removed legacy "auto" still resolves to Background via the existing parse, so an unrecognised value never silently fronts. Schemas continue to advertise delivery_mode only. This is the acceptance-side counterpart to the input.delivery_mode capability advertisement work (#2425): capability advertisement lets new clients detect support; this alias keeps old clients functional. Verified live end-to-end with a 0.5.x-era client: background title-bar drag -> structured refusal; retry with dispatch:"foreground" -> SendInput path taken and the window moved. Adds unit test delivery_mode_accepts_legacy_dispatch_alias. Co-Authored-By: Claude Fable 5 (cherry picked from commit f7dbf1a89bc70f44a7c9519a1070226f25bc615a) * fix(cua-driver): normalize legacy delivery before authorization Move the hidden dispatch compatibility alias to the canonical ToolRegistry boundary so policy, protected-resource authorization, recording, replay, and platform execution all receive the same modern delivery_mode value. Keep modern-field precedence, fail closed to background, and leave schemas modern-only. Co-authored-by: Edwin Gnichtel <88679181+ngnichtel@users.noreply.github.com> --------- Co-authored-by: Edwin Gnichtel Co-authored-by: Claude Fable 5 Co-authored-by: Edwin Gnichtel <88679181+ngnichtel@users.noreply.github.com> --- .../rust/crates/cua-driver-core/src/tool.rs | 165 +++++++++++++++++- 1 file changed, 162 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs index d7d916bc51..c9d82805f0 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/tool.rs @@ -437,9 +437,7 @@ pub fn default_capabilities_for(tool_name: &str) -> Vec { /// richer live schema. pub fn advertised_capabilities_for(tool_name: &str, input_schema: &Value) -> Vec { let mut capabilities = default_capabilities_for(tool_name); - let accepts_delivery_mode = input_schema - .pointer("/properties/delivery_mode") - .is_some_and(Value::is_object); + let accepts_delivery_mode = schema_accepts_delivery_mode(input_schema); if accepts_delivery_mode && !capabilities .iter() @@ -450,6 +448,53 @@ pub fn advertised_capabilities_for(tool_name: &str, input_schema: &Value) -> Vec capabilities } +fn schema_accepts_delivery_mode(input_schema: &Value) -> bool { + input_schema + .pointer("/properties/delivery_mode") + .is_some_and(Value::is_object) +} + +/// Canonicalize the hidden pre-0.7 Windows `dispatch` compatibility alias. +/// +/// This runs at the native dispatch boundary before policy, protected-resource +/// authorization, recording, and platform execution. Every downstream +/// consumer therefore sees the same modern field and one of the two supported +/// values. The live schema remains modern-only, and schema gating prevents an +/// unrelated tool's `dispatch` argument from being reinterpreted. +/// +/// Presence of `delivery_mode` always wins, including when its value is null or +/// malformed. As with the platform parsers, only an explicit case-insensitive +/// `foreground` opts into foreground delivery; every other supplied value, +/// including the removed legacy `auto`, fails closed to `background`. +fn normalize_delivery_mode_args(tool: &ToolDef, args: &mut Value) { + if !schema_accepts_delivery_mode(&tool.input_schema) { + return; + } + let Some(arguments) = args.as_object_mut() else { + return; + }; + + let supplied = if arguments.contains_key("delivery_mode") { + arguments.get("delivery_mode") + } else { + arguments.get("dispatch") + }; + let Some(supplied) = supplied else { + return; + }; + let mode = if supplied + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("foreground")) + { + "foreground" + } else { + "background" + }; + + arguments.remove("dispatch"); + arguments.insert("delivery_mode".to_owned(), Value::String(mode.to_owned())); +} + /// Runtime-owned provenance for protected-resource admission. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtectedResourceOwnership { @@ -1022,6 +1067,9 @@ impl ToolRegistry { return ToolResult::error(format!("Unknown tool: {name}")); }; + // Normalize deprecated public argument spellings before any policy, + // consent, recording, or implementation layer interprets the call. + normalize_delivery_mode_args(tool.def(), &mut args); if let Err(result) = crate::action_target::normalize_action_target(resolved_name, &mut args) { return result; @@ -3568,6 +3616,46 @@ resources: assert_eq!(provider.requests.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn canonical_dispatch_normalizes_legacy_delivery_mode_before_execution() { + let hits = Arc::new(AtomicUsize::new(0)); + let last_args = Arc::new(Mutex::new(None)); + let mut registry = super::ToolRegistry::new(); + registry.register(Box::new(ArgumentProbe { + hits: hits.clone(), + last_args: last_args.clone(), + def: super::ToolDef { + name: "click".into(), + description: "test input".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "delivery_mode": crate::tool_schema::delivery_mode_schema() + } + }), + read_only: false, + destructive: false, + idempotent: false, + open_world: false, + }, + })); + let registry = Arc::new(registry); + + let result = registry + .invoke_with_context( + "click", + serde_json::json!({"dispatch": "foreground"}), + standard_context(), + ) + .await; + + assert_ne!(result.is_error, Some(true)); + assert_eq!(hits.load(Ordering::SeqCst), 1); + let received = last_args.lock().unwrap().clone().expect("arguments"); + assert_eq!(received["delivery_mode"], "foreground"); + assert!(received.get("dispatch").is_none()); + } + #[tokio::test] async fn standard_file_transfer_is_promptless_and_still_canonicalizes_paths() { let files = tempfile::tempdir().unwrap(); @@ -5036,6 +5124,77 @@ mod capability_tests { ); } + #[test] + fn delivery_mode_normalization_is_schema_gated_modern_first_and_fail_closed() { + let with_delivery_mode = super::ToolDef { + name: "click".into(), + description: "test input".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "delivery_mode": crate::tool_schema::delivery_mode_schema() + } + }), + read_only: false, + destructive: false, + idempotent: false, + open_world: false, + }; + let without_delivery_mode = super::ToolDef { + name: "other".into(), + description: "unrelated tool".into(), + input_schema: serde_json::json!({"type": "object", "properties": {}}), + read_only: false, + destructive: false, + idempotent: false, + open_world: false, + }; + + for (mut args, expected) in [ + (serde_json::json!({"dispatch": "foreground"}), "foreground"), + (serde_json::json!({"dispatch": "Foreground"}), "foreground"), + (serde_json::json!({"dispatch": "background"}), "background"), + (serde_json::json!({"dispatch": "auto"}), "background"), + (serde_json::json!({"dispatch": "unknown"}), "background"), + (serde_json::json!({"dispatch": null}), "background"), + ( + serde_json::json!({"delivery_mode": "Foreground"}), + "foreground", + ), + ( + serde_json::json!({"delivery_mode": "unknown"}), + "background", + ), + (serde_json::json!({"delivery_mode": null}), "background"), + ( + serde_json::json!({ + "delivery_mode": "background", + "dispatch": "foreground" + }), + "background", + ), + ( + serde_json::json!({ + "delivery_mode": null, + "dispatch": "foreground" + }), + "background", + ), + ] { + super::normalize_delivery_mode_args(&with_delivery_mode, &mut args); + assert_eq!(args["delivery_mode"], expected, "arguments: {args}"); + assert!(args.get("dispatch").is_none(), "arguments: {args}"); + } + + let mut absent = serde_json::json!({"x": 1}); + super::normalize_delivery_mode_args(&with_delivery_mode, &mut absent); + assert_eq!(absent, serde_json::json!({"x": 1})); + + let mut unrelated = serde_json::json!({"dispatch": "foreground"}); + super::normalize_delivery_mode_args(&without_delivery_mode, &mut unrelated); + assert_eq!(unrelated, serde_json::json!({"dispatch": "foreground"})); + } + #[test] fn unknown_tools_get_empty_capabilities() { // Tools without a mapping (typically internal/stub tools like From 1542a717ac65b500d1a524152bcf742d4d311c63 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Thu, 20 Aug 2026 20:00:03 -0700 Subject: [PATCH 090/117] fix(cua-driver): preserve TCC across release updates (#3297) * fix(cua-driver): preserve TCC across release updates * fix(cua-driver): fail closed on macOS app identity * fix(cua-driver): harden macOS app upgrade rollback * fix(cua-driver): close macOS upgrade cleanup gaps --- libs/cua-driver/scripts/_install-rust.sh | 274 ++++++++++++++++-- .../scripts/tests/test_install_macos_tcc.py | 240 +++++++++++++++ 2 files changed, 484 insertions(+), 30 deletions(-) create mode 100644 libs/cua-driver/scripts/tests/test_install_macos_tcc.py diff --git a/libs/cua-driver/scripts/_install-rust.sh b/libs/cua-driver/scripts/_install-rust.sh index 6a1183d04f..d60d314187 100644 --- a/libs/cua-driver/scripts/_install-rust.sh +++ b/libs/cua-driver/scripts/_install-rust.sh @@ -178,6 +178,70 @@ TMP_DIR=$(mktemp -d) log() { printf '==> %s\n' "$*"; } err() { printf 'error: %s\n' "$*" >&2; } +# Return the source form of an app's designated code-signing requirement. +macos_designated_requirement() { + codesign -d -r- "$1" 2>/dev/null \ + | sed -n -e 's/^designated => //p' -e 's/^# designated => //p' +} + +# TCC stores the previous app's requirement, not just its bundle identifier. +# Ask Security.framework (through codesign) whether the replacement satisfies +# that exact requirement instead of comparing requirement text or cdhashes. +macos_requirement_compatibility() { + local previous_requirement="$1" + local candidate_app="$2" + local codesign_output + local codesign_status + + if [[ -z "$previous_requirement" ]]; then + printf '%s' "unknown" + elif codesign_output="$(codesign --verify --deep --strict \ + -R "=$previous_requirement" "$candidate_app" 2>&1)"; then + printf '%s' "compatible" + else + codesign_status=$? + if [[ "$codesign_status" == "3" ]]; then + printf '%s' "incompatible" + else + printf 'warning: could not evaluate the previous code-signing requirement (codesign status %s); preserving TCC rows\n' \ + "$codesign_status" >&2 + [[ -z "$codesign_output" ]] \ + || printf 'warning: codesign: %s\n' "$codesign_output" >&2 + printf '%s' "unknown" + fi + fi +} + +# Reset only the permissions Cua Driver itself consumes, and only after the +# newly installed bundle has been verified and registered with LaunchServices. +macos_reset_tcc_after_requirement_change() { + local compatibility="$1" + local bundle_id="com.trycua.driver" + local failed_services="" + local service + + [[ "$compatibility" == "incompatible" ]] || return 0 + if ! command -v tccutil >/dev/null 2>&1; then + err "tccutil is required to clear stale Cua Driver permission rows after its signing requirement changed" + return 1 + fi + for service in Accessibility ScreenCapture; do + if ! tccutil reset "$service" "$bundle_id" >/dev/null 2>&1; then + failed_services="$failed_services $service" + fi + done + if [[ -n "$failed_services" ]]; then + err "could not reset these TCC services for $bundle_id:$failed_services" + err "the new app is installed, but stale permission rows may remain; run:" + err " tccutil reset Accessibility $bundle_id" + err " tccutil reset ScreenCapture $bundle_id" + return 1 + fi + + log "the app signing requirement changed; cleared stale Accessibility and Screen Recording rows" + log "macOS authorization is required again: cua-driver permissions grant" +} + # --- Concurrent-install lockfile --------------------------------------- # # A second install kicked off while a first is still running can race @@ -209,6 +273,36 @@ LOCK_POLL_INTERVAL_SECONDS=1 LOCK_STALE_AFTER_SECONDS=600 LOCK_HELD=0 +MACOS_APP_SWAP_STARTED=0 +MACOS_APP_HAD_PREVIOUS=0 +MACOS_APP_INSTALL_COMMITTED=0 +MACOS_APP_BACKUP="" + +restore_macos_app_backup_on_exit() { + [[ "$MACOS_APP_SWAP_STARTED" == "1" ]] || return 0 + + if [[ "$MACOS_APP_INSTALL_COMMITTED" == "1" ]]; then + if [[ -e "$MACOS_APP_BACKUP" ]] && ! rm -rf "$MACOS_APP_BACKUP"; then + printf 'warning: could not remove macOS install backup at %s\n' \ + "$MACOS_APP_BACKUP" >&2 + fi + return 0 + fi + + if [[ "$MACOS_APP_HAD_PREVIOUS" == "1" ]]; then + # If the backup does not exist, the atomic move never completed or an + # explicit rollback already restored it. Leave the live path alone. + if [[ -e "$MACOS_APP_BACKUP" ]]; then + rm -rf "$APP_DEST" + mv "$MACOS_APP_BACKUP" "$APP_DEST" + printf 'warning: interrupted macOS install restored the previous CuaDriver.app\n' >&2 + fi + else + # A first install has no app to restore; remove only its partial copy. + rm -rf "$APP_DEST" + fi +} + release_install_lock() { if (( LOCK_HELD == 1 )); then rm -rf "$LOCK_DIR" 2>/dev/null || true @@ -220,6 +314,7 @@ release_install_lock() { # clobbers the other. INT/TERM also re-raise via $? so the user-visible # exit code reflects the signal. cleanup_on_exit() { + restore_macos_app_backup_on_exit rm -rf "$TMP_DIR" 2>/dev/null || true release_install_lock } @@ -402,12 +497,9 @@ prune_old_releases() { # under the home. Every step is best-effort + idempotent; a machine with no # prior local install is a clean no-op. # -# TCC is preserved deliberately: we do NOT `tccutil reset` here. The bundle at -# /Applications/CuaDriver.app is shared (bundle id com.trycua.driver) and the -# subsequent release `ditto` re-points the binary in place; grants keyed on the -# bundle id survive (macOS may re-prompt once on the cdhash change, same as any -# upgrade). Churning the signing identity would gratuitously invalidate -# cert-pinned grants, so we leave it alone. +# The release install below verifies the previous and replacement designated +# requirements. Compatible releases preserve grants; a proven mismatch resets +# only the stale Cua Driver rows after the replacement is registered. cleanup_prior_local_install() { local releases_dir="$HOME_DIR/packages/releases" local tcc_marker="$HOME_DIR/.tcc-signing-identity" @@ -936,11 +1028,9 @@ fi # `realpath` walk in `is_executable_inside_cuadriver_app()` keys on # that resolved path to know whether the auto-relaunch heuristic # should fire. Same path and same bundle id as the Swift `cua-driver` -# install (`/Applications/CuaDriver.app`, `com.trycua.driver`), so an -# install over an existing Swift bundle is an in-place takeover — -# TCC grants attributed to the shared bundle id survive the swap and -# the new binary inherits them (macOS may re-prompt once on first -# action because the cdhash differs; after that the grants persist). +# install (`/Applications/CuaDriver.app`, `com.trycua.driver`). The installer +# also proves whether the replacement satisfies the previous app's designated +# requirement; the bundle identifier alone is not enough to preserve TCC. # # The macOS path intentionally does NOT use the # $HOME_DIR/packages/releases// + current symlink layout used on @@ -968,22 +1058,36 @@ if [[ "$OS" == "Darwin" ]]; then exit 1 fi fi +DAEMONS_STOPPED_BEFORE_SWAP=0 if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]]; then if [[ ! -w "/Applications" ]]; then err "/Applications is not writable. Re-run this installer in a shell where it is, or grant write access." err " Without the .app bundle, \`cua-driver-rs mcp\` from an IDE terminal will not auto-relaunch into a TCC-correct daemon." exit 1 fi - # The Rust port and the legacy Swift driver both live at - # /Applications/CuaDriver.app with bundle id `com.trycua.driver` — - # bundle-id-identical so TCC grants survive the upgrade. When we - # detect a prior Swift bundle at the install path we log it for - # transparency, but no `tccutil reset` is needed; grants transfer - # automatically because they're keyed on bundle id. macOS may - # surface a one-time re-prompt on first action because the cdhash - # of the new binary doesn't match the old one — that's a TCC - # cdhash-pairing detail, not a grant loss. + if ! command -v codesign >/dev/null 2>&1; then + err "codesign is required to verify the macOS release app safely" + exit 1 + fi + if ! codesign --verify --deep --strict "$SRC_APP" 2>/dev/null; then + err "downloaded CuaDriver.app failed signature verification; the installed app was not changed" + exit 1 + fi + STAGED_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \ + "$SRC_APP/Contents/Info.plist" 2>/dev/null || true) + if [[ "$STAGED_BUNDLE_ID" != "com.trycua.driver" ]]; then + err "downloaded app has unexpected bundle id ${STAGED_BUNDLE_ID:-}; the installed app was not changed" + exit 1 + fi + STAGED_REQUIREMENT="$(macos_designated_requirement "$SRC_APP" || true)" + if [[ -z "$STAGED_REQUIREMENT" ]]; then + err "could not read the downloaded app's designated requirement; the installed app was not changed" + exit 1 + fi + REPLACED_SWIFT=0 + PREVIOUS_REQUIREMENT="" + REQUIREMENT_COMPATIBILITY="unknown" if [[ -e "$APP_DEST" ]]; then PREV_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_DEST/Contents/Info.plist" 2>/dev/null || true) PREV_BUNDLE_VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DEST/Contents/Info.plist" 2>/dev/null || true) @@ -995,20 +1099,117 @@ if [[ "$OS" == "Darwin" && -n "$SRC_APP" && -d "$SRC_APP" ]]; then else log "removing existing $APP_DEST" fi - rm -rf "$APP_DEST" + if [[ "$PREV_BUNDLE_ID" == "com.trycua.driver" ]] \ + && codesign --verify --deep --strict "$APP_DEST" 2>/dev/null; then + PREVIOUS_REQUIREMENT="$(macos_designated_requirement "$APP_DEST" || true)" + if [[ -n "$PREVIOUS_REQUIREMENT" ]]; then + REQUIREMENT_COMPATIBILITY="$(macos_requirement_compatibility \ + "$PREVIOUS_REQUIREMENT" "$SRC_APP")" + else + log "warning: could not read the existing app's designated requirement; preserving TCC rows because compatibility is unknown" + fi + elif [[ "$PREV_BUNDLE_ID" == "com.trycua.driver" ]]; then + log "warning: existing CuaDriver.app signature could not be verified; preserving TCC rows because compatibility is unknown" + else + log "warning: the existing app does not own com.trycua.driver; it will not be used to decide whether Cua Driver TCC rows are stale" + fi + fi + + # Stop the old daemon while its verified bundle still exists. This avoids + # leaving a running process whose executable path disappears mid-upgrade. + stop_cua_driver_daemons + show_cua_driver_daemon_survivors + DAEMONS_STOPPED_BEFORE_SWAP=1 + + MACOS_APP_BACKUP="${APP_DEST}.install-backup.$$" + if [[ -e "$MACOS_APP_BACKUP" ]]; then + err "temporary backup path already exists: $MACOS_APP_BACKUP" + exit 1 + fi + if [[ -e "$APP_DEST" ]]; then + MACOS_APP_HAD_PREVIOUS=1 + fi + MACOS_APP_SWAP_STARTED=1 + if [[ "$MACOS_APP_HAD_PREVIOUS" == "1" ]]; then + mv "$APP_DEST" "$MACOS_APP_BACKUP" fi log "installing $APP_DEST" # `ditto` preserves the bundle's metadata + nested symlinks the way # Apple's installer would. `cp -R` works but doesn't preserve as # much, and ditto is always present on macOS. - ditto "$SRC_APP" "$APP_DEST" + INSTALL_VALID=0 + if ditto "$SRC_APP" "$APP_DEST" \ + && codesign --verify --deep --strict "$APP_DEST" 2>/dev/null; then + INSTALLED_BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \ + "$APP_DEST/Contents/Info.plist" 2>/dev/null || true) + INSTALLED_REQUIREMENT="$(macos_designated_requirement "$APP_DEST" || true)" + if [[ "$INSTALLED_BUNDLE_ID" == "$STAGED_BUNDLE_ID" \ + && -n "$INSTALLED_REQUIREMENT" \ + && "$INSTALLED_REQUIREMENT" == "$STAGED_REQUIREMENT" ]]; then + INSTALL_VALID=1 + fi + fi + if [[ "$INSTALL_VALID" != "1" ]]; then + rm -rf "$APP_DEST" + if [[ -e "$MACOS_APP_BACKUP" ]]; then + mv "$MACOS_APP_BACKUP" "$APP_DEST" + fi + err "installed CuaDriver.app did not preserve its verified signing identity; the replacement was rolled back" + exit 1 + fi APP_BINARY="$APP_DEST/Contents/MacOS/$BINARY_NAME" if [[ ! -x "$APP_BINARY" ]]; then - err "binary missing at $APP_BINARY (refusing to create broken symlink)" + rm -rf "$APP_DEST" + if [[ -e "$MACOS_APP_BACKUP" ]]; then + mv "$MACOS_APP_BACKUP" "$APP_DEST" + fi + err "binary missing at $APP_BINARY; the replacement was rolled back" exit 1 fi + + # Register synchronously so both `open -a CuaDriver` and `tccutil reset` + # resolve the replacement bundle rather than a stale LaunchServices entry. + LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" + if [[ -x "$LSREGISTER" ]] \ + && "$LSREGISTER" -f "$APP_DEST" >/dev/null 2>&1; then + : + else + rm -rf "$APP_DEST" + if [[ -e "$MACOS_APP_BACKUP" ]]; then + mv "$MACOS_APP_BACKUP" "$APP_DEST" + "$LSREGISTER" -f "$APP_DEST" >/dev/null 2>&1 || true + fi + err "could not register the replacement app with LaunchServices; the replacement was rolled back" + exit 1 + fi + + # Re-check the installed copy against the old requirement. A disagreement + # with the staged result means the copy did not preserve the expected code + # identity and must not trigger a destructive permission reset. + if [[ -n "$PREVIOUS_REQUIREMENT" ]]; then + INSTALLED_COMPATIBILITY="$(macos_requirement_compatibility \ + "$PREVIOUS_REQUIREMENT" "$APP_DEST")" + if [[ "$INSTALLED_COMPATIBILITY" != "$REQUIREMENT_COMPATIBILITY" ]]; then + rm -rf "$APP_DEST" + if [[ -e "$MACOS_APP_BACKUP" ]]; then + mv "$MACOS_APP_BACKUP" "$APP_DEST" + fi + if [[ "$INSTALLED_COMPATIBILITY" == "unknown" ]]; then + err "could not re-verify the installed app's signing compatibility; the replacement was rolled back" + else + err "installed app's signing compatibility changed during copy; the replacement was rolled back" + fi + exit 1 + fi + fi + + MACOS_APP_INSTALL_COMMITTED=1 + rm -rf "$MACOS_APP_BACKUP" || true ln -sf "$APP_BINARY" "$BIN_LINK" log "symlinked $BIN_LINK -> $APP_BINARY" + if ! macos_reset_tcc_after_requirement_change "$REQUIREMENT_COMPATIBILITY"; then + exit 1 + fi else # Linux: versioned-dirs + atomic `current` symlink swap. # @@ -1113,8 +1314,10 @@ fi # LaunchAgent / systemd user unit / manual `serve` shell keeps serving # pre-upgrade behaviour until logout, which is what surfaces to users # as "the bug I just fixed is still there". -stop_cua_driver_daemons -show_cua_driver_daemon_survivors +if [[ "$DAEMONS_STOPPED_BEFORE_SWAP" != "1" ]]; then + stop_cua_driver_daemons + show_cua_driver_daemon_survivors +fi # Agent skill pack: NOT auto-linked. The install script never touches # ~/.claude/skills/, ~/.agents/skills/, etc. Run `cua-driver skills @@ -1166,11 +1369,22 @@ echo "" if [[ "${REPLACED_SWIFT:-0}" == "1" ]]; then echo "Upgraded the cua-driver bundle that was previously at $APP_DEST." - echo "TCC grants (Accessibility, Screen Recording) are keyed on the bundle id" - echo "(com.trycua.driver) — which is preserved — so they transfer to the new" - echo "binary automatically. macOS may surface a one-time re-grant prompt on" - echo "first action because the new binary's cdhash doesn't match the old" - echo "one's; approve once and the grants persist." + case "${REQUIREMENT_COMPATIBILITY:-unknown}" in + compatible) + echo "Verified that the replacement satisfies the previous code-signing" + echo "requirement, so existing Accessibility and Screen Recording grants" + echo "were preserved." + ;; + incompatible) + echo "The code-signing requirement changed, so stale Accessibility and" + echo "Screen Recording rows were cleared. Re-authorize the new app with:" + echo " cua-driver permissions grant" + ;; + *) + echo "The previous code-signing requirement could not be verified. Existing" + echo "TCC rows were left unchanged to avoid destroying valid grants." + ;; + esac echo "" fi diff --git a/libs/cua-driver/scripts/tests/test_install_macos_tcc.py b/libs/cua-driver/scripts/tests/test_install_macos_tcc.py new file mode 100644 index 0000000000..e2d373e946 --- /dev/null +++ b/libs/cua-driver/scripts/tests/test_install_macos_tcc.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import os +import re +import subprocess +from pathlib import Path + + +INSTALLER = Path(__file__).resolve().parents[1] / "_install-rust.sh" + + +def extract_shell_function(name: str) -> str: + source = INSTALLER.read_text() + match = re.search( + rf"(?ms)^{re.escape(name)}\(\) \{{\n.*?^\}}\n", + source, + ) + assert match, f"could not find shell function {name}" + return match.group(0) + + +def run_policy(body: str) -> subprocess.CompletedProcess[str]: + functions = "\n".join( + extract_shell_function(name) + for name in ( + "macos_requirement_compatibility", + "macos_reset_tcc_after_requirement_change", + ) + ) + return subprocess.run( + ["/bin/bash", "-c", f"set -euo pipefail\n{functions}\n{body}"], + check=False, + capture_output=True, + text=True, + ) + + +def run_rollback_policy(body: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + function = extract_shell_function("restore_macos_app_backup_on_exit") + return subprocess.run( + ["/bin/bash", "-c", f"set -euo pipefail\n{function}\n{body}"], + check=False, + capture_output=True, + text=True, + env={**os.environ, **env}, + ) + + +def test_semantically_compatible_requirement_preserves_tcc_rows() -> None: + result = run_policy( + r''' + err() { printf 'error: %s\n' "$*" >&2; } + log() { printf 'log: %s\n' "$*"; } + codesign() { + [[ "$1" == "--verify" ]] + [[ "$4" == '-R' ]] + [[ "$5" == '=identifier "com.trycua.driver" and anchor apple generic' ]] + [[ "$6" == '/replacement.app' ]] + } + tccutil() { echo unexpected >&2; return 99; } + compatibility="$(macos_requirement_compatibility \ + 'identifier "com.trycua.driver" and anchor apple generic' \ + /replacement.app)" + [[ "$compatibility" == compatible ]] + macos_reset_tcc_after_requirement_change "$compatibility" + ''' + ) + + assert result.returncode == 0, result.stderr + assert "unexpected" not in result.stderr + + +def test_incompatible_requirement_resets_only_driver_permissions() -> None: + result = run_policy( + r''' + err() { printf 'error: %s\n' "$*" >&2; } + log() { printf 'log: %s\n' "$*"; } + codesign() { return 3; } + calls="" + tccutil() { calls="${calls}${1}:${2}:${3}"$'\n'; } + compatibility="$(macos_requirement_compatibility 'old requirement' /replacement.app)" + [[ "$compatibility" == incompatible ]] + macos_reset_tcc_after_requirement_change "$compatibility" + printf '%s' "$calls" + ''' + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == ( + "log: the app signing requirement changed; cleared stale Accessibility and Screen Recording rows\n" + "log: macOS authorization is required again: cua-driver permissions grant\n" + "reset:Accessibility:com.trycua.driver\n" + "reset:ScreenCapture:com.trycua.driver\n" + ) + + +def test_unknown_previous_requirement_does_not_destroy_grants() -> None: + result = run_policy( + r''' + err() { printf 'error: %s\n' "$*" >&2; } + log() { printf 'log: %s\n' "$*"; } + codesign() { echo unexpected >&2; return 99; } + tccutil() { echo unexpected >&2; return 99; } + compatibility="$(macos_requirement_compatibility '' /replacement.app)" + [[ "$compatibility" == unknown ]] + macos_reset_tcc_after_requirement_change "$compatibility" + ''' + ) + + assert result.returncode == 0, result.stderr + assert "unexpected" not in result.stderr + + +def test_requirement_evaluation_error_does_not_destroy_grants() -> None: + result = run_policy( + r''' + err() { printf 'error: %s\n' "$*" >&2; } + log() { printf 'log: %s\n' "$*"; } + codesign() { echo 'malformed requirement' >&2; return 1; } + tccutil() { echo unexpected >&2; return 99; } + compatibility="$(macos_requirement_compatibility 'malformed' /replacement.app)" + [[ "$compatibility" == unknown ]] + macos_reset_tcc_after_requirement_change "$compatibility" + ''' + ) + + assert result.returncode == 0, result.stderr + assert "could not evaluate the previous code-signing requirement" in result.stderr + assert "unexpected" not in result.stderr + + +def test_reset_failure_is_actionable_and_returns_failure() -> None: + result = run_policy( + r''' + err() { printf 'error: %s\n' "$*" >&2; } + log() { printf 'log: %s\n' "$*"; } + tccutil() { [[ "$2" != ScreenCapture ]]; } + if macos_reset_tcc_after_requirement_change incompatible; then + exit 90 + fi + ''' + ) + + assert result.returncode == 0, result.stderr + assert "could not reset these TCC services" in result.stderr + assert "tccutil reset Accessibility com.trycua.driver" in result.stderr + assert "tccutil reset ScreenCapture com.trycua.driver" in result.stderr + + +def test_installer_verifies_then_registers_before_any_tcc_reset() -> None: + source = INSTALLER.read_text() + install = source.index('if [[ "$OS" == "Darwin" && -n "$SRC_APP"') + staged_verify = source.index('codesign --verify --deep --strict "$SRC_APP"', install) + stop_daemon = source.index("stop_cua_driver_daemons", staged_verify) + backup = source.index('mv "$APP_DEST" "$MACOS_APP_BACKUP"', staged_verify) + copy = source.index('ditto "$SRC_APP" "$APP_DEST"', backup) + installed_verify = source.index('codesign --verify --deep --strict "$APP_DEST"', copy) + register = source.index('"$LSREGISTER" -f "$APP_DEST"', installed_verify) + commit = source.index("MACOS_APP_INSTALL_COMMITTED=1", register) + link = source.index('ln -sf "$APP_BINARY" "$BIN_LINK"', commit) + reset = source.index("macos_reset_tcc_after_requirement_change", link) + + assert staged_verify < stop_daemon < backup < copy < installed_verify < register < commit < link < reset + + +def test_only_the_release_bundle_identity_can_trigger_a_tcc_reset() -> None: + source = INSTALLER.read_text() + + assert 'STAGED_BUNDLE_ID' in source + assert 'STAGED_BUNDLE_ID" != "com.trycua.driver"' in source + assert '[[ "$PREV_BUNDLE_ID" == "com.trycua.driver" ]]' in source + assert 'INSTALLED_BUNDLE_ID" == "$STAGED_BUNDLE_ID"' in source + + +def test_exit_cleanup_restores_the_previous_app(tmp_path: Path) -> None: + app = tmp_path / "CuaDriver.app" + backup = tmp_path / "CuaDriver.app.install-backup" + app.mkdir() + (app / "candidate").write_text("partial") + backup.mkdir() + (backup / "previous").write_text("valid") + + result = run_rollback_policy( + "restore_macos_app_backup_on_exit", + { + "APP_DEST": str(app), + "MACOS_APP_BACKUP": str(backup), + "MACOS_APP_SWAP_STARTED": "1", + "MACOS_APP_HAD_PREVIOUS": "1", + "MACOS_APP_INSTALL_COMMITTED": "0", + }, + ) + + assert result.returncode == 0, result.stderr + assert (app / "previous").read_text() == "valid" + assert not backup.exists() + + +def test_exit_cleanup_removes_a_partial_first_install(tmp_path: Path) -> None: + app = tmp_path / "CuaDriver.app" + app.mkdir() + (app / "candidate").write_text("partial") + + result = run_rollback_policy( + "restore_macos_app_backup_on_exit", + { + "APP_DEST": str(app), + "MACOS_APP_BACKUP": str(tmp_path / "missing-backup"), + "MACOS_APP_SWAP_STARTED": "1", + "MACOS_APP_HAD_PREVIOUS": "0", + "MACOS_APP_INSTALL_COMMITTED": "0", + }, + ) + + assert result.returncode == 0, result.stderr + assert not app.exists() + + +def test_exit_cleanup_leaves_a_committed_install_untouched(tmp_path: Path) -> None: + app = tmp_path / "CuaDriver.app" + backup = tmp_path / "CuaDriver.app.install-backup" + app.mkdir() + (app / "candidate").write_text("valid") + backup.mkdir() + (backup / "previous").write_text("old") + + result = run_rollback_policy( + "restore_macos_app_backup_on_exit", + { + "APP_DEST": str(app), + "MACOS_APP_BACKUP": str(backup), + "MACOS_APP_SWAP_STARTED": "1", + "MACOS_APP_HAD_PREVIOUS": "0", + "MACOS_APP_INSTALL_COMMITTED": "1", + }, + ) + + assert result.returncode == 0, result.stderr + assert (app / "candidate").read_text() == "valid" + assert not backup.exists() From 58dfd87c6e70f1693677f5c24f75f3276e0d2c5b Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 21 Aug 2026 07:11:06 -0700 Subject: [PATCH 091/117] chore: restore injaneity as Cua Driver code owner (#3308) --- .github/CODEOWNERS | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1147b08485..6bc72f2ec5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,34 +4,34 @@ /release-please-config.json @f-trycua # Core products and SDKs. -/libs/cua-driver/ @f-trycua +/libs/cua-driver/ @f-trycua @injaneity /libs/lume/ @f-trycua /libs/python/ @ddupont808 /libs/cua-bench/ @ddupont808 # Cua Driver documentation and driver-only release/CI automation. -/docs/content/docs/reference/cua-driver/ @f-trycua -/scripts/docs-generators/cua-driver.ts @f-trycua -/.github/release-notes/cua-driver-rs.md @f-trycua -/.github/scripts/sync_driver_release_docs.py @f-trycua -/.github/scripts/verify_cua_driver_release_archives.py @f-trycua -/.github/scripts/tests/test_cua_driver_release_wiring.py @f-trycua -/.github/scripts/tests/test_sync_driver_release_docs.py @f-trycua -/.github/scripts/tests/test_verify_cua_driver_release_archives.py @f-trycua -/.github/workflows/cd-cua-driver-docs.yml @f-trycua -/.github/workflows/cd-py-cua-driver.yml @f-trycua -/.github/workflows/cd-rust-cua-driver.yml @f-trycua -/.github/workflows/ci-cua-driver-contract-clients.yml @f-trycua -/.github/workflows/ci-cua-driver-installer-compat.yml @f-trycua -/.github/workflows/ci-distro-compat-cua-driver.yml @f-trycua -/.github/workflows/ci-rust-format.yml @f-trycua -/.github/workflows/ci-rust-linux.yml @f-trycua -/.github/workflows/ci-rust-windows.yml @f-trycua -/.github/workflows/clawhub-cua-driver.yml @f-trycua -/.github/workflows/e2e-rust-linux-wayland.yml @f-trycua -/.github/workflows/e2e-rust-linux.yml @f-trycua -/.github/workflows/e2e-rust-standalone-browsers.yml @f-trycua -/.github/workflows/e2e-rust-windows.yml @f-trycua +/docs/content/docs/reference/cua-driver/ @f-trycua @injaneity +/scripts/docs-generators/cua-driver.ts @f-trycua @injaneity +/.github/release-notes/cua-driver-rs.md @f-trycua @injaneity +/.github/scripts/sync_driver_release_docs.py @f-trycua @injaneity +/.github/scripts/verify_cua_driver_release_archives.py @f-trycua @injaneity +/.github/scripts/tests/test_cua_driver_release_wiring.py @f-trycua @injaneity +/.github/scripts/tests/test_sync_driver_release_docs.py @f-trycua @injaneity +/.github/scripts/tests/test_verify_cua_driver_release_archives.py @f-trycua @injaneity +/.github/workflows/cd-cua-driver-docs.yml @f-trycua @injaneity +/.github/workflows/cd-py-cua-driver.yml @f-trycua @injaneity +/.github/workflows/cd-rust-cua-driver.yml @f-trycua @injaneity +/.github/workflows/ci-cua-driver-contract-clients.yml @f-trycua @injaneity +/.github/workflows/ci-cua-driver-installer-compat.yml @f-trycua @injaneity +/.github/workflows/ci-distro-compat-cua-driver.yml @f-trycua @injaneity +/.github/workflows/ci-rust-format.yml @f-trycua @injaneity +/.github/workflows/ci-rust-linux.yml @f-trycua @injaneity +/.github/workflows/ci-rust-windows.yml @f-trycua @injaneity +/.github/workflows/clawhub-cua-driver.yml @f-trycua @injaneity +/.github/workflows/e2e-rust-linux-wayland.yml @f-trycua @injaneity +/.github/workflows/e2e-rust-linux.yml @f-trycua @injaneity +/.github/workflows/e2e-rust-standalone-browsers.yml @f-trycua @injaneity +/.github/workflows/e2e-rust-windows.yml @f-trycua @injaneity # Platform infrastructure. /libs/fleet/ @r33drichards From a6828955187f3554682065c88f6cb770f107095a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=AE=80=E5=BE=8B=E7=BA=AF?= Date: Sat, 22 Aug 2026 01:34:40 +0800 Subject: [PATCH 092/117] fix(cua-driver): match canonical Windows executable paths (#3299) * fix(cua-driver): match canonical Windows executable paths * fix(cua-driver): canonicalize Windows process fingerprints --- .../platform-windows/src/browser_platform.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs index 7f8fd9c120..b1ecbfc7ff 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/browser_platform.rs @@ -698,7 +698,8 @@ fn process_identity(pid: u32) -> Result<(u64, Option), BrowserRefusal> { } .ok() .filter(|_| path_len > 0) - .map(|_| String::from_utf16_lossy(&path_buf[..path_len as usize])); + .map(|_| String::from_utf16_lossy(&path_buf[..path_len as usize])) + .map(canonical_process_executable); let _ = unsafe { CloseHandle(handle) }; times.map_err(|error| { refusal( @@ -710,6 +711,14 @@ fn process_identity(pid: u32) -> Result<(u64, Option), BrowserRefusal> { Ok((started, path)) } +fn canonical_process_executable(path: String) -> String { + // Manifest executable grants use `canonicalize` too. Normalize the Windows + // process evidence at collection time so shared authorization stays exact. + std::fs::canonicalize(&path) + .map(|canonical| canonical.to_string_lossy().into_owned()) + .unwrap_or(path) +} + fn cdp_comparable_window_bounds(window_id: u64) -> Result { let hwnd = HWND(window_id as *mut _); let mut outer = RECT::default(); @@ -1920,6 +1929,18 @@ impl BrowserPlatform for WindowsBrowserPlatform { mod tests { use super::*; + #[test] + fn process_fingerprint_uses_manifest_canonical_executable_path() { + let (_started, executable) = + process_identity(std::process::id()).expect("current process fingerprint"); + let expected = std::fs::canonicalize(std::env::current_exe().expect("current executable")) + .expect("canonical current executable") + .to_string_lossy() + .into_owned(); + + assert_eq!(executable.as_deref(), Some(expected.as_str())); + } + #[test] fn isolated_browser_candidates_are_vendor_attested_protected_installs() { let candidates = isolated_browser_candidates_from_roots( From 0213cd82fd8f5f35d530e7b3eda5286511bbbc10 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Fri, 21 Aug 2026 14:07:45 -0700 Subject: [PATCH 093/117] fix(cua-driver): sign Windows release binaries (#3320) --- .../tests/test_cua_driver_release_wiring.py | 48 +++++++++++++++++ .github/workflows/cd-rust-cua-driver.yml | 54 ++++++++++++++++--- .github/workflows/nightly-cua-driver.yml | 1 + 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/.github/scripts/tests/test_cua_driver_release_wiring.py b/.github/scripts/tests/test_cua_driver_release_wiring.py index 8a13f338b6..289c9454e1 100644 --- a/.github/scripts/tests/test_cua_driver_release_wiring.py +++ b/.github/scripts/tests/test_cua_driver_release_wiring.py @@ -576,6 +576,54 @@ def test_driver_attribution_preflight_gates_candidate_builds(self) -> None: 1, ) + def test_driver_windows_release_signs_every_pe_binary_before_packaging(self) -> None: + workflow = self.read(".github/workflows/cd-rust-cua-driver.yml") + windows = workflow.index(" build-windows:") + next_job = workflow.index(" verify-windows-node-runtime:", windows) + block = workflow[windows:next_job] + + self.assertIn(" id-token: write\n", workflow) + self.assertIn(" environment: cua-driver-release-signing\n", block) + self.assertIn( + "azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca", + block, + ) + self.assertIn( + "azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82", + block, + ) + self.assertIn("${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }}", block) + self.assertIn("${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }}", block) + self.assertIn( + "${{ vars.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}", + block, + ) + for binary in ( + "cua-driver.exe", + "cua-cursor-theme.exe", + "cua-driver-uia.exe", + "cua_driver_sdk.dll", + "cua_driver_node_runtime.node", + ): + self.assertIn(binary, block) + + login = block.index("- name: Azure login for Artifact Signing") + signing = block.index("- name: Sign Windows binaries", login) + verify = block.index("- name: Verify Authenticode signatures", signing) + package = block.index("- name: Package", verify) + self.assertLess(login, signing) + self.assertLess(signing, verify) + self.assertLess(verify, package) + self.assertIn("Get-AuthenticodeSignature", block) + self.assertIn("$signature.Status -ne 'Valid'", block) + self.assertIn("Cua AI, Inc", block) + self.assertNotIn("currently shipped UNSIGNED", block) + + def test_driver_nightly_grants_oidc_to_reusable_signing_workflow(self) -> None: + workflow = self.read(".github/workflows/nightly-cua-driver.yml") + self.assertIn(" id-token: write\n", workflow) + self.assertIn("uses: ./.github/workflows/cd-rust-cua-driver.yml", workflow) + def test_driver_tag_build_cannot_publish_before_manual_e2e_gate(self) -> None: workflow = self.read(".github/workflows/cd-rust-cua-driver.yml") self.assertIn( diff --git a/.github/workflows/cd-rust-cua-driver.yml b/.github/workflows/cd-rust-cua-driver.yml index 679f1a0759..949d5bbfe8 100644 --- a/.github/workflows/cd-rust-cua-driver.yml +++ b/.github/workflows/cd-rust-cua-driver.yml @@ -72,6 +72,7 @@ on: permissions: contents: write + id-token: write issues: read pull-requests: read @@ -265,6 +266,7 @@ jobs: name: windows-${{ matrix.arch }} needs: release-attribution-preflight runs-on: windows-latest + environment: cua-driver-release-signing strategy: fail-fast: false matrix: @@ -360,6 +362,51 @@ jobs: if ($dynamicCrt) { throw "Windows Node runtime has a dynamic VC/UCRT dependency: $($dynamicCrt -join ', ')" } + - name: Azure login for Artifact Signing + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Sign Windows binaries + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ vars.AZURE_ARTIFACT_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.AZURE_ARTIFACT_SIGNING_ACCOUNT_NAME }} + certificate-profile-name: ${{ vars.AZURE_ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }} + files: | + ${{ github.workspace }}\libs\cua-driver\rust\target\${{ matrix.target }}\release\cua-driver.exe + ${{ github.workspace }}\libs\cua-driver\rust\target\${{ matrix.target }}\release\cua-cursor-theme.exe + ${{ github.workspace }}\libs\cua-driver\rust\target\${{ matrix.target }}\release\cua-driver-uia.exe + ${{ github.workspace }}\libs\cua-driver\rust\target\${{ matrix.target }}\release\cua_driver_sdk.dll + ${{ github.workspace }}\libs\cua-driver\rust\target\${{ matrix.target }}\release\cua_driver_node_runtime.node + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: Cua Driver + description-url: https://cua.ai + - name: Verify Authenticode signatures + working-directory: libs/cua-driver/rust + shell: pwsh + run: | + $target = "${{ matrix.target }}" + $files = @( + "target/$target/release/cua-driver.exe", + "target/$target/release/cua-cursor-theme.exe", + "target/$target/release/cua-driver-uia.exe", + "target/$target/release/cua_driver_sdk.dll", + "target/$target/release/cua_driver_node_runtime.node" + ) + foreach ($file in $files) { + $signature = Get-AuthenticodeSignature -FilePath $file + $signature | Format-List Status, StatusMessage, Path, SignerCertificate + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature for ${file}: $($signature.StatusMessage)" + } + if ($signature.SignerCertificate.Subject -notmatch 'CN="?Cua AI, Inc\."?') { + throw "Unexpected Authenticode signer for ${file}: $($signature.SignerCertificate.Subject)" + } + } - name: Package working-directory: libs/cua-driver/rust shell: pwsh @@ -383,12 +430,7 @@ jobs: # wrapper and dumps the files at the archive root, which breaks # the installer's path lookup. Compress-Archive -Path "release/$stage" -DestinationPath "release/$stage.zip" -Force - # Bare-binaries zip: both exes side-by-side (no wrapper dir). - # `cua-driver-uia.exe` is currently shipped UNSIGNED in this archive - # — until #1602 wires an EV cert into CD, the worker won't elevate - # to UIAccess integrity on a default-policy machine. The main CLI/MCP - # binary stays fully functional regardless; uia is opt-in dead-weight - # until signed. + # Bare-binaries zip: all runtime binaries side-by-side (no wrapper dir). Compress-Archive -Path "release/$stage/cua-driver.exe","release/$stage/cua-cursor-theme.exe","release/$stage/cua-driver-uia.exe","release/$stage/cua_driver_sdk.dll","release/$stage/cua_driver_node_runtime.node","release/$stage/cua_driver_abi.h" -DestinationPath "release/$stage-binary.zip" -Force Get-ChildItem "release/$stage*.zip" - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/nightly-cua-driver.yml b/.github/workflows/nightly-cua-driver.yml index 8e234018bc..ea2c3f08d3 100644 --- a/.github/workflows/nightly-cua-driver.yml +++ b/.github/workflows/nightly-cua-driver.yml @@ -17,6 +17,7 @@ on: permissions: contents: write + id-token: write issues: read pull-requests: read From e4b0a03e739ffd0c6ba9d000a54c6fabf54752d9 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Fri, 21 Aug 2026 19:37:27 -0700 Subject: [PATCH 094/117] fix(cua-sandbox): support local Linux on ARM64 (#3257) * chore: start ARM64 local Linux sandbox fix * fix(cua-sandbox): publish local Linux image for ARM64 * fix(xfce): build computer-server native dependencies * test(xfce): defer computer-server import until X starts * test(xfce): harden multi-arch container smoke diagnostics * fix(cua-sandbox): consume unified multiarch Linux image * fix(cua-cli): honor Linux image port contract * test(cua-sandbox): add Apple Silicon Docker live coverage * test(cua-sandbox): add uv Apple Silicon handoff * style(cua-sandbox): black-format live Docker script Co-Authored-By: Claude Fable 5 --------- Co-authored-by: r33drichards Co-authored-by: Claude Fable 5 --- handoff.md | 104 ++++++++++ .../cua-cli/cua_cli/commands/local_image.py | 8 +- .../cua-cli/cua_cli/commands/platform.py | 4 +- .../tests/commands/test_local_image.py | 38 ++++ .../cua-sandbox/cua_sandbox/runtime/images.py | 4 +- .../scripts/live_local_linux_docker.py | 193 ++++++++++++++++++ .../tests/live/test_local_linux_docker.py | 30 +++ .../cua-sandbox/tests/test_runtime_images.py | 8 + 8 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 handoff.md create mode 100644 libs/python/cua-cli/tests/commands/test_local_image.py create mode 100755 libs/python/cua-sandbox/scripts/live_local_linux_docker.py create mode 100644 libs/python/cua-sandbox/tests/live/test_local_linux_docker.py create mode 100644 libs/python/cua-sandbox/tests/test_runtime_images.py diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000000..67ef0de633 --- /dev/null +++ b/handoff.md @@ -0,0 +1,104 @@ +# Apple Silicon Local Linux Docker E2E Handoff + +## Objective + +Validate `trycua/cua#3257` on a real Apple Silicon Mac using Docker Desktop. The run must prove that the ARM64 image starts through `cua-sandbox`, computer-server is reachable through Docker's published host port, desktop screenshots and clipboard operations work, and the ephemeral container is removed. + +Do not modify code, create commits, push branches, or change the pull request. Run the test and report evidence only. + +## Prerequisites + +- Apple Silicon Mac (`uname -m` returns `arm64`) +- Docker Desktop running +- `uv` installed +- Local clone of `https://github.com/trycua/cua` +- Branch `codex/cua-sandbox-arm64-linux` +- Candidate Docker image tag or digest, if testing before `docker-latest` is published + +## Checkout + +```bash +git fetch origin codex/cua-sandbox-arm64-linux +git switch codex/cua-sandbox-arm64-linux +git pull --ff-only origin codex/cua-sandbox-arm64-linux +git status --short +``` + +Stop if the worktree is not clean. + +## Record Environment + +```bash +uname -a +uname -m +docker version +uv --version +``` + +## Candidate Image Run + +Use the immutable Docker image produced by `trycua/cloud#7099`. A local Docker tag is also accepted. + +```bash +IMAGE='' + +docker pull "$IMAGE" || docker image inspect "$IMAGE" + +docker image inspect "$IMAGE" \ + --format 'repo_digests={{json .RepoDigests}} architecture={{.Architecture}} os={{.Os}}' + +./libs/python/cua-sandbox/scripts/live_local_linux_docker.py \ + --image "" +``` + +Expected terminal result: + +```text +PASS: local Linux Docker sandbox is healthy +``` + +## Published Default Run + +Run this only after `public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04:docker-latest` exists. This verifies the image resolution shipped by `cua#3257` rather than an explicit override. + +```bash +./libs/python/cua-sandbox/scripts/live_local_linux_docker.py +``` + +## Success Criteria + +The script must verify all of the following: + +- host architecture is Apple Silicon ARM64; +- the Linux container reports `aarch64`; +- computer-server accepts shell commands through the published Docker port; +- screenshot is a valid PNG larger than 10 KB; +- screen dimensions are positive; +- clipboard write/read returns the exact marker; +- the ephemeral Docker container is removed after exit. + +Artifacts are written to `/tmp/cua-linux-arm64-live`: + +- `summary.json` +- `screenshot.png` +- `docker-inspect.json` on failure +- `docker.log` on failure + +## Failure Report + +Return: + +1. Full command output. +2. `/tmp/cua-linux-arm64-live/summary.json`. +3. `/tmp/cua-linux-arm64-live/docker.log`, if present. +4. `/tmp/cua-linux-arm64-live/docker-inspect.json`, if present. +5. Output from: + +```bash +docker ps -a --filter label=cua.sandbox=true +docker images --digests | grep -E 'cua-ubuntu|cua-xfce|xfce-cua' || true +git status --short +git rev-parse HEAD +``` + +Do not mark the validation successful if the test only passes under `--platform linux/amd64`; native ARM64 execution is required. diff --git a/libs/python/cua-cli/cua_cli/commands/local_image.py b/libs/python/cua-cli/cua_cli/commands/local_image.py index 42997e459a..830a76fed0 100644 --- a/libs/python/cua-cli/cua_cli/commands/local_image.py +++ b/libs/python/cua-cli/cua_cli/commands/local_image.py @@ -771,6 +771,10 @@ def _shell_linux_docker(args: argparse.Namespace, info: dict, name: str) -> int: print_error(f"No Docker image configured for '{name}'") return 1 + config = PLATFORMS["linux-docker"] + internal_vnc_port = config["internal_vnc_port"] + internal_api_port = config["internal_api_port"] + user_vnc = getattr(args, "vnc_port", None) user_api = getattr(args, "api_port", None) @@ -806,9 +810,9 @@ def _shell_linux_docker(args: argparse.Namespace, info: dict, name: str) -> int: "-t", "--rm", "-p", - f"{vnc_port}:6901", + f"{vnc_port}:{internal_vnc_port}", "-p", - f"{api_port}:8000", + f"{api_port}:{internal_api_port}", "--name", container_name, docker_image, diff --git a/libs/python/cua-cli/cua_cli/commands/platform.py b/libs/python/cua-cli/cua_cli/commands/platform.py index 8f1f22af70..3b3f82b786 100644 --- a/libs/python/cua-cli/cua_cli/commands/platform.py +++ b/libs/python/cua-cli/cua_cli/commands/platform.py @@ -24,9 +24,9 @@ PLATFORMS: Dict[str, Dict[str, Any]] = { "linux-docker": { - "image": "trycua/cua-xfce:latest", + "image": "public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04:docker-latest", "description": "Linux GUI container (no KVM required)", - "internal_vnc_port": 6901, + "internal_vnc_port": 6080, "internal_api_port": 8000, "requires_kvm": False, "image_marker": None, diff --git a/libs/python/cua-cli/tests/commands/test_local_image.py b/libs/python/cua-cli/tests/commands/test_local_image.py new file mode 100644 index 0000000000..b23a192270 --- /dev/null +++ b/libs/python/cua-cli/tests/commands/test_local_image.py @@ -0,0 +1,38 @@ +from argparse import Namespace +from types import SimpleNamespace + +from cua_cli.commands import local_image + + +def test_linux_docker_shell_uses_platform_internal_ports(monkeypatch) -> None: + calls = [] + + def fake_run(command, **kwargs): + calls.append(command) + if command[:2] == ["docker", "ps"]: + return SimpleNamespace(stdout="", stderr="", returncode=0) + return SimpleNamespace(stdout="container-id\n", stderr="", returncode=0) + + monkeypatch.setattr(local_image.subprocess, "run", fake_run) + + result = local_image._shell_linux_docker( + Namespace(vnc_port=16901, api_port=18000, detach=True), + {"docker_image": "example.invalid/cua-linux:test"}, + "linux-docker", + ) + + assert result == 0 + assert calls[-1] == [ + "docker", + "run", + "-d", + "-t", + "--rm", + "-p", + "16901:6080", + "-p", + "18000:8000", + "--name", + "cua-shell-linux-docker", + "example.invalid/cua-linux:test", + ] diff --git a/libs/python/cua-sandbox/cua_sandbox/runtime/images.py b/libs/python/cua-sandbox/cua_sandbox/runtime/images.py index bd5e787ff8..285b261626 100644 --- a/libs/python/cua-sandbox/cua_sandbox/runtime/images.py +++ b/libs/python/cua-sandbox/cua_sandbox/runtime/images.py @@ -2,7 +2,7 @@ # ── Docker image tags ──────────────────────────────────────────────────────── -UBUNTU_XFCE = "trycua/cua-xfce:latest" +UBUNTU_XFCE = "public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04:docker-latest" QEMU_LINUX = "trycua/cua-qemu-linux:latest" QEMU_WINDOWS = "trycua/cua-qemu-windows:latest" QEMU_ANDROID = "trycua/cua-qemu-android:latest" @@ -21,7 +21,7 @@ # ── Internal ports (inside the container) ──────────────────────────────────── XFCE_API_PORT = 8000 -XFCE_VNC_PORT = 6901 +XFCE_VNC_PORT = 6080 QEMU_API_PORT = 5000 QEMU_VNC_PORT = 8006 diff --git a/libs/python/cua-sandbox/scripts/live_local_linux_docker.py b/libs/python/cua-sandbox/scripts/live_local_linux_docker.py new file mode 100755 index 0000000000..f614f4b31e --- /dev/null +++ b/libs/python/cua-sandbox/scripts/live_local_linux_docker.py @@ -0,0 +1,193 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11,<3.14" +# dependencies = ["cua-sandbox"] +# +# [tool.uv.sources] +# cua-sandbox = { path = "..", editable = true } +# +# [[tool.uv.index]] +# name = "cua-wheels" +# url = "https://wheels.cua.ai/simple" +# /// +"""Exercise the local Linux Docker sandbox on a real host.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import platform +import subprocess +import uuid +from pathlib import Path +from typing import Any + +from cua_sandbox import Image, Sandbox + + +def run_command(*command: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True, check=False) + + +def require_docker() -> str: + result = run_command("docker", "version", "--format", "{{.Server.Version}}") + if result.returncode != 0: + raise RuntimeError(f"Docker is unavailable: {result.stderr.strip()}") + return result.stdout.strip() + + +def expected_container_architecture(host_architecture: str) -> str: + normalized = host_architecture.lower() + if normalized in {"arm64", "aarch64"}: + return "aarch64" + if normalized in {"amd64", "x86_64"}: + return "x86_64" + raise RuntimeError(f"Unsupported host architecture: {host_architecture}") + + +def write_summary(artifact_dir: Path, summary: dict[str, Any]) -> None: + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / "summary.json").write_text( + json.dumps(summary, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def collect_container_diagnostics(name: str, artifact_dir: Path) -> None: + artifact_dir.mkdir(parents=True, exist_ok=True) + for label, command in { + "docker-inspect.json": ("docker", "inspect", name), + "docker.log": ("docker", "logs", name), + }.items(): + result = run_command(*command) + output = result.stdout + if result.stderr: + output += f"\n--- stderr ---\n{result.stderr}" + (artifact_dir / label).write_text(output, encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the cua-sandbox local Linux Docker live test.", + ) + parser.add_argument( + "--image", + default=os.environ.get("CUA_TEST_LINUX_DOCKER_IMAGE"), + help="Candidate image reference. Omit to test the built-in Linux container image.", + ) + parser.add_argument( + "--artifact-dir", + type=Path, + default=Path(os.environ.get("CUA_TEST_ARTIFACT_DIR", "/tmp/cua-linux-arm64-live")), + help="Directory for screenshot, summary, and failure diagnostics.", + ) + parser.add_argument( + "--allow-non-arm64", + action="store_true", + help="Permit execution on amd64 for diagnostic use.", + ) + return parser.parse_args() + + +async def run_live_test(args: argparse.Namespace) -> None: + host_architecture = platform.machine().lower() + if not args.allow_non_arm64 and host_architecture not in {"arm64", "aarch64"}: + raise RuntimeError( + f"Apple Silicon is required, found {host_architecture}. " + "Pass --allow-non-arm64 only for diagnostic runs." + ) + + docker_version = require_docker() + expected_architecture = expected_container_architecture(host_architecture) + if args.image: + image = Image.from_registry(args.image, os_type="linux", kind="container") + image_description = args.image + else: + image = Image.linux(kind="container") + image_description = "built-in Linux container image" + + name = f"cua-linux-live-{uuid.uuid4().hex[:8]}" + clipboard_marker = f"{name}-{uuid.uuid4().hex}" + summary: dict[str, Any] = { + "docker_server_version": docker_version, + "host_architecture": host_architecture, + "image": image_description, + "sandbox_name": name, + "success": False, + } + + print(f"Host architecture: {host_architecture}") + print(f"Docker server: {docker_version}") + print(f"Image: {image_description}") + print(f"Artifacts: {args.artifact_dir}") + + try: + async with Sandbox.ephemeral( + image, + local=True, + name=name, + telemetry_enabled=False, + ) as sandbox: + try: + architecture = await sandbox.shell.run("uname -m") + if not architecture.success: + raise AssertionError(architecture.stderr) + container_architecture = architecture.stdout.strip() + summary["container_architecture"] = container_architecture + if container_architecture != expected_architecture: + raise AssertionError( + f"expected container architecture {expected_architecture}, " + f"got {container_architecture}" + ) + + screenshot = await sandbox.screenshot() + if not screenshot.startswith(b"\x89PNG\r\n\x1a\n"): + raise AssertionError("screenshot is not a PNG") + if len(screenshot) <= 10_000: + raise AssertionError( + f"screenshot is suspiciously small: {len(screenshot)} bytes" + ) + args.artifact_dir.mkdir(parents=True, exist_ok=True) + (args.artifact_dir / "screenshot.png").write_bytes(screenshot) + summary["screenshot_bytes"] = len(screenshot) + + width, height = await sandbox.get_dimensions() + if width <= 0 or height <= 0: + raise AssertionError(f"invalid screen dimensions: {width}x{height}") + summary["screen"] = {"width": width, "height": height} + + await sandbox.clipboard.set(clipboard_marker) + clipboard_value = await sandbox.clipboard.get() + if clipboard_value != clipboard_marker: + raise AssertionError( + f"clipboard mismatch: expected {clipboard_marker!r}, got {clipboard_value!r}" + ) + summary["clipboard_round_trip"] = True + except BaseException: + collect_container_diagnostics(name, args.artifact_dir) + raise + except BaseException as error: + summary["error"] = {"message": str(error), "type": type(error).__name__} + write_summary(args.artifact_dir, summary) + raise + + inspect = run_command("docker", "inspect", "--type", "container", name) + if inspect.returncode == 0: + summary["cleanup_error"] = f"ephemeral container {name!r} was not removed" + write_summary(args.artifact_dir, summary) + raise AssertionError(summary["cleanup_error"]) + + summary["cleanup_verified"] = True + summary["success"] = True + write_summary(args.artifact_dir, summary) + print("PASS: local Linux Docker sandbox is healthy") + + +def main() -> None: + asyncio.run(run_live_test(parse_args())) + + +if __name__ == "__main__": + main() diff --git a/libs/python/cua-sandbox/tests/live/test_local_linux_docker.py b/libs/python/cua-sandbox/tests/live/test_local_linux_docker.py new file mode 100644 index 0000000000..af4c4fa919 --- /dev/null +++ b/libs/python/cua-sandbox/tests/live/test_local_linux_docker.py @@ -0,0 +1,30 @@ +"""Opt-in pytest wrapper for the local Linux Docker live script.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[2] / "scripts" / "live_local_linux_docker.py" +ENABLED = os.environ.get("CUA_TEST_LOCAL_LINUX_DOCKER", "").lower() in { + "1", + "true", + "yes", +} + +pytestmark = pytest.mark.skipif( + not ENABLED, + reason="CUA_TEST_LOCAL_LINUX_DOCKER is not enabled", +) + + +def test_local_linux_docker_live_script() -> None: + result = subprocess.run( + [str(SCRIPT)], + text=True, + check=False, + ) + assert result.returncode == 0 diff --git a/libs/python/cua-sandbox/tests/test_runtime_images.py b/libs/python/cua-sandbox/tests/test_runtime_images.py new file mode 100644 index 0000000000..4eb08821e5 --- /dev/null +++ b/libs/python/cua-sandbox/tests/test_runtime_images.py @@ -0,0 +1,8 @@ +from cua_sandbox.runtime.images import internal_ports, resolve_image + + +def test_linux_docker_uses_unified_multiarch_rootfs() -> None: + image = resolve_image("linux") + + assert image == "public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04:docker-latest" + assert internal_ports(image) == (8000, 6080) From b296ec9cbf460f360cf8ea2e203c26bcc92614b0 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Fri, 21 Aug 2026 20:37:51 -0700 Subject: [PATCH 095/117] chore(release): cua-sandbox 0.4.3, cua-cli 0.1.15 (#3325) Ships the native ARM64 local Linux Docker sandbox (#3257, refs #3254). cua-cli uv.lock also syncs to the already-pinned cua-fleet==0.1.14. Co-authored-by: Claude Fable 5 --- libs/python/cua-cli/pyproject.toml | 2 +- libs/python/cua-cli/uv.lock | 18 +++++++++--------- libs/python/cua-sandbox/pyproject.toml | 2 +- libs/python/cua-sandbox/uv.lock | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/libs/python/cua-cli/pyproject.toml b/libs/python/cua-cli/pyproject.toml index 1523838fda..51e396f86d 100644 --- a/libs/python/cua-cli/pyproject.toml +++ b/libs/python/cua-cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cua-cli" -version = "0.1.14" +version = "0.1.15" description = "Unified CLI for CUA - Computer-Use Agents" readme = "README.md" license = "MIT" diff --git a/libs/python/cua-cli/uv.lock b/libs/python/cua-cli/uv.lock index 4880e53c51..def6e6ffaf 100644 --- a/libs/python/cua-cli/uv.lock +++ b/libs/python/cua-cli/uv.lock @@ -484,7 +484,7 @@ all = [ [[package]] name = "cua-cli" -version = "0.1.14" +version = "0.1.15" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -571,19 +571,19 @@ wheels = [ [[package]] name = "cua-fleet" -version = "0.1.8" +version = "0.1.14" source = { registry = "https://wheels.cua.ai/simple" } wheels = [ - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:20611a14c185157e6f41502e1bbdcc1e98663347e94463ca538755b0efc173bb" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c21d0a36a8d74bfe0768422e4f3d9367ee51837920e2c22ee57521fc93c4f3b" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.8-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:d7604668a7186d06cefb2bd23974b033c0b2cda61ccbe88d944af622b37f58d3" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.8-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:0bac329552d351604ad15b7eb4f4f3ed944921083238d74f51277d657d8f0a34" }, - { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.8-py3-none-win_amd64.whl", hash = "sha256:d78fe6934a2f650dcf5b105a08833418245c720765da9c3b40e160b86a3d203d" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d370f83773574da8edcca080e9d86aec8eb7ed758d6c551b900482a8575f6810" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e19784c0ce8aa2d9a77bf096fc6ff10e990842f05c67bdfb96a16ecd5e7123e2" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-manylinux_2_34_aarch64.whl", hash = "sha256:d6ea25902cf9ffc89779530b6ae7cff5413383ea7dc3c632a4fb47e00699a6d4" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-manylinux_2_34_x86_64.whl", hash = "sha256:2dc70e98b3e8c691bf0fff0494b0a85d2405e872f1ca99dbfa2680473cea565b" }, + { url = "https://wheels.cua.ai/simple/cua-fleet/cua_fleet-0.1.14-py3-none-win_amd64.whl", hash = "sha256:3e73327b7c99dc95a4b4194628d3575a8707cab77d6929ed1bf34a82192a4464" }, ] [[package]] name = "cua-sandbox" -version = "0.1.28" +version = "0.4.3" source = { editable = "../cua-sandbox" } dependencies = [ { name = "cua-auto" }, @@ -603,7 +603,7 @@ dependencies = [ requires-dist = [ { name = "cua-auto", specifier = ">=0.1.2" }, { name = "cua-core", specifier = ">=0.3.0,<0.4.0" }, - { name = "cua-fleet", specifier = "==0.1.8" }, + { name = "cua-fleet", specifier = "==0.1.14" }, { name = "grpcio", specifier = "==1.78.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "oras", specifier = ">=0.2.40" }, diff --git a/libs/python/cua-sandbox/pyproject.toml b/libs/python/cua-sandbox/pyproject.toml index c568e1d353..d6d1a212ce 100644 --- a/libs/python/cua-sandbox/pyproject.toml +++ b/libs/python/cua-sandbox/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cua-sandbox" -version = "0.4.2" +version = "0.4.3" description = "CUA Sandbox — ephemeral and persistent sandboxed computer environments" readme = "README.md" license = "MIT" diff --git a/libs/python/cua-sandbox/uv.lock b/libs/python/cua-sandbox/uv.lock index 1daa96aebc..df081a6c48 100644 --- a/libs/python/cua-sandbox/uv.lock +++ b/libs/python/cua-sandbox/uv.lock @@ -540,7 +540,7 @@ wheels = [ [[package]] name = "cua-sandbox" -version = "0.4.2" +version = "0.4.3" source = { editable = "." } dependencies = [ { name = "cua-auto" }, From 737dc2a069528abadee67526d138a907e1c52061 Mon Sep 17 00:00:00 2001 From: r33drichards Date: Sat, 22 Aug 2026 16:57:59 -0700 Subject: [PATCH 096/117] docs(sandbox): drop incorrect Cua cloud registry-image restriction (#3335) The images guide claimed that only Cua-published images are pullable on Cua cloud. That is not a real restriction, so remove the sentence and leave the accurate anonymous-auth limitation in place. Claude-Session: https://claude.ai/code/session_01WNLhYB8qxvaBCDwPBLVrcP Co-authored-by: Robert Co-authored-by: Claude --- docs/content/docs/how-to-guides/sandbox/images.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/how-to-guides/sandbox/images.mdx b/docs/content/docs/how-to-guides/sandbox/images.mdx index abcbcbc7ef..5064492073 100644 --- a/docs/content/docs/how-to-guides/sandbox/images.mdx +++ b/docs/content/docs/how-to-guides/sandbox/images.mdx @@ -217,7 +217,7 @@ async with Sandbox.ephemeral(img, local=True, runtime=QEMURuntime(mode='bare-met does not set `os_type` — every registry image reports `os_type='linux'`, including the macOS one above, so pass a runtime matching the real guest. And the puller authenticates anonymously, which works for `public.ecr.aws` and `ghcr.io` but not - for Docker Hub. On Cua cloud, only Cua-published images are pullable. + for Docker Hub. ## Use a local disk image From 4d524657c9490ff6c15fbf4a6d9301ba429b152d Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Sun, 23 Aug 2026 21:31:27 -0700 Subject: [PATCH 097/117] chore(cua-sandbox): synchronize bumpversion release metadata (#3352) --- libs/python/cua-sandbox/.bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/python/cua-sandbox/.bumpversion.cfg b/libs/python/cua-sandbox/.bumpversion.cfg index 99612cfd8b..97702fd042 100644 --- a/libs/python/cua-sandbox/.bumpversion.cfg +++ b/libs/python/cua-sandbox/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.4.2 +current_version = 0.4.3 commit = True tag = True tag_name = sandbox-v{new_version} From ac9b1643acb61e20d5af078215e7d1b8c414db89 Mon Sep 17 00:00:00 2001 From: "cua-release-bot[bot]" <254316371+cua-release-bot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:07:50 -0700 Subject: [PATCH 098/117] chore(main): release cua-driver-rs 0.22.0 Release Cua Driver 0.22.0. --- .release-please-manifest.json | 2 +- .../reference/cua-driver/cli-reference.mdx | 4 +-- .../docs/reference/cua-driver/mcp-tools.mdx | 2 +- libs/cua-driver/python/pyproject.toml | 2 +- .../python/src/cua_driver/__init__.py | 2 +- libs/cua-driver/rust/CHANGELOG.md | 19 ++++++++++++++ libs/cua-driver/rust/Cargo.lock | 26 +++++++++---------- libs/cua-driver/rust/Cargo.toml | 2 +- .../rust/Skills/cua-driver/SKILL.md | 2 +- libs/cua-driver/rust/VERSION | 2 +- libs/cua-driver/typescript/package-lock.json | 4 +-- libs/cua-driver/typescript/package.json | 2 +- 12 files changed, 44 insertions(+), 25 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7f9339269c..fde2268961 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - "libs/cua-driver": "0.21.0", + "libs/cua-driver": "0.22.0", "libs/lume": "0.5.3" } diff --git a/docs/content/docs/reference/cua-driver/cli-reference.mdx b/docs/content/docs/reference/cua-driver/cli-reference.mdx index 3d1dcc1977..4b3c4c712d 100644 --- a/docs/content/docs/reference/cua-driver/cli-reference.mdx +++ b/docs/content/docs/reference/cua-driver/cli-reference.mdx @@ -7,7 +7,7 @@ description: Command-line interface specification for Cua Driver AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: cua-driver dump-docs - Version: 0.21.0 + Version: 0.22.0 */} Cross-platform computer-use automation driver. Install via the official script: @@ -16,7 +16,7 @@ Cross-platform computer-use automation driver. Install via the official script: curl -fsSL https://cua.ai/driver/install.sh | bash ``` -Documented against Cua Driver **0.21.0**. Run `cua-driver --version` for your installed version. +Documented against Cua Driver **0.22.0**. Run `cua-driver --version` for your installed version. The macOS-only `cua-driver permissions` command is documented separately in [macOS permissions](/reference/cua-driver/macos-permissions). diff --git a/docs/content/docs/reference/cua-driver/mcp-tools.mdx b/docs/content/docs/reference/cua-driver/mcp-tools.mdx index 1a2925c686..f44a669201 100644 --- a/docs/content/docs/reference/cua-driver/mcp-tools.mdx +++ b/docs/content/docs/reference/cua-driver/mcp-tools.mdx @@ -7,7 +7,7 @@ description: Reference for every MCP tool Cua Driver exposes AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY Generated by: npx tsx scripts/docs-generators/cua-driver.ts Source: cua-driver dump-docs - Version: 0.21.0 + Version: 0.22.0 */} import { Callout } from 'fumadocs-ui/components/callout'; diff --git a/libs/cua-driver/python/pyproject.toml b/libs/cua-driver/python/pyproject.toml index 2b478239ab..eb8422d4f8 100644 --- a/libs/cua-driver/python/pyproject.toml +++ b/libs/cua-driver/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "cua-driver" -version = "0.21.0" +version = "0.22.0" description = "Rust-backed Cua Driver SDK and bundled executable for client applications" readme = "README.md" license = { text = "MIT" } diff --git a/libs/cua-driver/python/src/cua_driver/__init__.py b/libs/cua-driver/python/src/cua_driver/__init__.py index aaab22c944..74a7d1f5f1 100644 --- a/libs/cua-driver/python/src/cua_driver/__init__.py +++ b/libs/cua-driver/python/src/cua_driver/__init__.py @@ -4,7 +4,7 @@ through their runtime's MCP client instead of importing a language MCP facade. """ -__version__ = "0.21.0" # x-release-please-version +__version__ = "0.22.0" # x-release-please-version from ._native import ( ActionCompletion, diff --git a/libs/cua-driver/rust/CHANGELOG.md b/libs/cua-driver/rust/CHANGELOG.md index d4de2fbf28..b655d2f4e3 100644 --- a/libs/cua-driver/rust/CHANGELOG.md +++ b/libs/cua-driver/rust/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.22.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.21.0...cua-driver-rs-v0.22.0) (2026-08-24) + + +### Features + +* **cua-driver:** configure embedded daemon overlay ([#3280](https://github.com/trycua/cua/issues/3280)) ([3ae080d](https://github.com/trycua/cua/commit/3ae080d366b0fc514ef03b8f5136f29e7477c2ae)) + + +### Bug Fixes + +* **cua-driver:** block uinput pointer hotplug on KDE X11 ([#2888](https://github.com/trycua/cua/issues/2888)) ([56f13c3](https://github.com/trycua/cua/commit/56f13c360a8b678960747c50f2680e40c14ae346)) +* **cua-driver:** honor HERMES_HOME for skill links ([#3291](https://github.com/trycua/cua/issues/3291)) ([9a61050](https://github.com/trycua/cua/commit/9a61050e3474fc9488d7adc85184299f02514d0e)) +* **cua-driver:** make Windows browser prepare language independent ([#3135](https://github.com/trycua/cua/issues/3135)) ([9bf47f6](https://github.com/trycua/cua/commit/9bf47f65dda1bc33bdd19dabb94c56b691f39ca0)) +* **cua-driver:** match canonical Windows executable paths ([#3299](https://github.com/trycua/cua/issues/3299)) ([a682895](https://github.com/trycua/cua/commit/a6828955187f3554682065c88f6cb770f107095a)) +* **cua-driver:** normalize legacy dispatch before authorization ([#3037](https://github.com/trycua/cua/issues/3037)) ([d138baa](https://github.com/trycua/cua/commit/d138baa96a1b4a379dfb498c17101f20cfcf7aef)) +* **cua-driver:** preserve embedded telemetry preference ([#3277](https://github.com/trycua/cua/issues/3277)) ([2c8fa2a](https://github.com/trycua/cua/commit/2c8fa2a73d87d858520a3acfabc9b6a54479960c)) +* **cua-driver:** preserve TCC across release updates ([#3297](https://github.com/trycua/cua/issues/3297)) ([1542a71](https://github.com/trycua/cua/commit/1542a717ac65b500d1a524152bcf742d4d311c63)) +* **cua-driver:** reset stale local TCC rows after ad-hoc rebuilds ([#2747](https://github.com/trycua/cua/issues/2747)) ([ee09e86](https://github.com/trycua/cua/commit/ee09e869727ce1f80793b0dd830a29139cb6fffa)) + ## [0.21.0](https://github.com/trycua/cua/compare/cua-driver-rs-v0.20.0...cua-driver-rs-v0.21.0) (2026-08-19) diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index fe2d59d49d..fa49ad653f 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "async-trait", @@ -1176,7 +1176,7 @@ dependencies = [ [[package]] name = "cua-driver-bindgen" -version = "0.21.0" +version = "0.22.0" dependencies = [ "cbindgen", "uniffi", @@ -1184,7 +1184,7 @@ dependencies = [ [[package]] name = "cua-driver-contract" -version = "0.21.0" +version = "0.22.0" dependencies = [ "schemars", "serde", @@ -1194,7 +1194,7 @@ dependencies = [ [[package]] name = "cua-driver-core" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "async-trait", @@ -1232,7 +1232,7 @@ dependencies = [ [[package]] name = "cua-driver-sdk" -version = "0.21.0" +version = "0.22.0" dependencies = [ "async-trait", "core-foundation", @@ -1254,7 +1254,7 @@ dependencies = [ [[package]] name = "cua-driver-testkit" -version = "0.21.0" +version = "0.22.0" dependencies = [ "core-foundation", "libc", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "cua-driver-uia" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "cua-driver-core", @@ -1283,7 +1283,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "cua-driver-contract", @@ -1300,7 +1300,7 @@ dependencies = [ [[package]] name = "cursor-theme-cli" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "cursor-overlay", @@ -3146,7 +3146,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pip-preview" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "serde_json", @@ -3206,7 +3206,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-linux" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "ashpd", @@ -3256,7 +3256,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "async-trait", @@ -3294,7 +3294,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.21.0" +version = "0.22.0" dependencies = [ "anyhow", "async-trait", diff --git a/libs/cua-driver/rust/Cargo.toml b/libs/cua-driver/rust/Cargo.toml index df90cdc80d..762484e5ab 100644 --- a/libs/cua-driver/rust/Cargo.toml +++ b/libs/cua-driver/rust/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "0.21.0" +version = "0.22.0" edition = "2021" authors = ["trycua"] license = "MIT" diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 4747855d66..84bbc03a6c 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -1,7 +1,7 @@ --- name: cua-driver description: Drive a native GUI app (macOS, Windows, Linux) via the cua-driver CLI (default) or MCP server; snapshot its accessibility tree, act through snapshot-bound element tokens, native menu paths, exact window geometry, or pixel coordinates, and verify from fresh state. Use when the user asks you to operate, drive, automate, or perform a GUI task in a real application on the host, or to continue, resume, or recall recent Cua activity. -version: 0.21.0 # x-release-please-version +version: 0.22.0 # x-release-please-version metadata: openclaw: requires: diff --git a/libs/cua-driver/rust/VERSION b/libs/cua-driver/rust/VERSION index 885415662f..2157409059 100644 --- a/libs/cua-driver/rust/VERSION +++ b/libs/cua-driver/rust/VERSION @@ -1 +1 @@ -0.21.0 +0.22.0 diff --git a/libs/cua-driver/typescript/package-lock.json b/libs/cua-driver/typescript/package-lock.json index 65dc5b27ee..05426970a9 100644 --- a/libs/cua-driver/typescript/package-lock.json +++ b/libs/cua-driver/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trycua/cua-driver", - "version": "0.21.0", + "version": "0.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trycua/cua-driver", - "version": "0.21.0", + "version": "0.22.0", "license": "MIT", "repository": { "type": "git", diff --git a/libs/cua-driver/typescript/package.json b/libs/cua-driver/typescript/package.json index eef155d4f1..d6f2a57b0f 100644 --- a/libs/cua-driver/typescript/package.json +++ b/libs/cua-driver/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@trycua/cua-driver", - "version": "0.21.0", + "version": "0.22.0", "description": "Rust-backed Cua Driver SDK and embedded Node host", "type": "module", "license": "MIT", From 76bea1442ee1a89ef1665477bea2e3744b4788a7 Mon Sep 17 00:00:00 2001 From: Rodri Mora <5622210+RodriMora@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:47:15 +0200 Subject: [PATCH 099/117] fix(cua-driver): capture identified Hyprland toplevels Resolve one Hyprland client from compositor title/app-id plus the verified target PID, then capture its own surface through hyprland-toplevel-export-v1. Ambiguous or unavailable identities retain the typed fail-closed result. Preserve existing capture behavior on other Wayland compositors. Salvaged from #1876. Co-authored-by: shuv --- .github/release-attribution-config.json | 1 + libs/cua-driver/rust/Cargo.lock | 1 + .../rust/crates/platform-linux/Cargo.toml | 2 + .../protocols/hyprland-toplevel-export-v1.xml | 228 +++++++++ .../platform-linux/src/recording_hooks.rs | 5 +- .../crates/platform-linux/src/tools/impl_.rs | 16 +- .../platform-linux/src/wayland/hyprland.rs | 161 +++++++ .../src/wayland/hyprland_capture.rs | 452 ++++++++++++++++++ .../crates/platform-linux/src/wayland/mod.rs | 80 +++- 9 files changed, 922 insertions(+), 24 deletions(-) create mode 100644 libs/cua-driver/rust/crates/platform-linux/protocols/hyprland-toplevel-export-v1.xml create mode 100644 libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs create mode 100644 libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland_capture.rs diff --git a/.github/release-attribution-config.json b/.github/release-attribution-config.json index 327f9d4b82..fdb03dfb2c 100644 --- a/.github/release-attribution-config.json +++ b/.github/release-attribution-config.json @@ -33,6 +33,7 @@ "rwendt1337@gmail.com": "r33drichards", "rsyuzyov@gmail.com": "rsyuzyov", "sarinajin.li@gmail.com": "sarinali", + "shuv@shuv.dev": "shuv1337", "zane.chee.2023@scis.smu.edu.sg": "injaneity", "zongxin_yang@hms.harvard.edu": "z-x-yang" }, diff --git a/libs/cua-driver/rust/Cargo.lock b/libs/cua-driver/rust/Cargo.lock index fa49ad653f..24e4f46879 100644 --- a/libs/cua-driver/rust/Cargo.lock +++ b/libs/cua-driver/rust/Cargo.lock @@ -3213,6 +3213,7 @@ dependencies = [ "async-trait", "atspi", "base64", + "bitflags 2.11.1", "calloop", "clipboard-rs", "crossbeam-channel", diff --git a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml index 5a54b732aa..91aeee1ee2 100644 --- a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml @@ -46,6 +46,8 @@ meval = "0.2" wayland-client = "0.31" wayland-backend = "0.3" wayland-scanner = "0.31" +# Generated hyprland-toplevel-export-v1 bindings reference bitflags directly. +bitflags = "2" wayland-protocols-wlr = { version = "0.3", features = ["client"] } # Wayland staging protocols: ext-foreign-toplevel-list-v1, # ext-image-capture-source-v1, ext-image-copy-capture-v1 (per-window diff --git a/libs/cua-driver/rust/crates/platform-linux/protocols/hyprland-toplevel-export-v1.xml b/libs/cua-driver/rust/crates/platform-linux/protocols/hyprland-toplevel-export-v1.xml new file mode 100644 index 0000000000..b1185aa54f --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-linux/protocols/hyprland-toplevel-export-v1.xml @@ -0,0 +1,228 @@ + + + + Copyright © 2022 Vaxry + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + + This protocol allows clients to ask for exporting another toplevel's + surface(s) to a buffer. + + Particularly useful for sharing a single window. + + + + + This object is a manager which offers requests to start capturing from a + source. + + + + + Capture the next frame of a toplevel. (window) + + The captured frame will not contain any server-side decorations and will + ignore the compositor-set geometry, like e.g. rounded corners. + + It will contain all the subsurfaces and popups, however the latter will be clipped + to the geometry of the base surface. + + The handle parameter refers to the address of the window as seen in `hyprctl clients`. + For example, for d161e7b0 it would be 3512854448. + + + + + + + + + All objects created by the manager will still remain valid, until their + appropriate destroy request has been called. + + + + + + + Same as capture_toplevel, but with a zwlr_foreign_toplevel_handle_v1 handle. + + + + + + + + + + + This object represents a single frame. + + When created, a series of buffer events will be sent, each representing a + supported buffer type. The "buffer_done" event is sent afterwards to + indicate that all supported buffer types have been enumerated. The client + will then be able to send a "copy" request. If the capture is successful, + the compositor will send a "flags" followed by a "ready" event. + + wl_shm buffers are always supported, ie. the "buffer" event is guaranteed to be sent. + + If the capture failed, the "failed" event is sent. This can happen anytime + before the "ready" event. + + Once either a "ready" or a "failed" event is received, the client should + destroy the frame. + + + + + Provides information about wl_shm buffer parameters that need to be + used for this frame. This event is sent once after the frame is created + if wl_shm buffers are supported. + + + + + + + + + + Copy the frame to the supplied buffer. The buffer must have the + correct size, see hyprland_toplevel_export_frame_v1.buffer and + hyprland_toplevel_export_frame_v1.linux_dmabuf. The buffer needs to have a + supported format. + + If the frame is successfully copied, a "flags" and a "ready" event is + sent. Otherwise, a "failed" event is sent. + + This event will wait for appropriate damage to be copied, unless the ignore_damage + arg is set to a non-zero value. + + + + + + + + This event is sent right before the ready event when ignore_damage was + not set. It may be generated multiple times for each copy + request. + + The arguments describe a box around an area that has changed since the + last copy request that was derived from the current screencopy manager + instance. + + The union of all regions received between the call to copy + and a ready event is the total damage since the prior ready event. + + + + + + + + + + + + + + + + + + + Provides flags about the frame. This event is sent once before the + "ready" event. + + + + + + + Called as soon as the frame is copied, indicating it is available + for reading. This event includes the time at which presentation happened + at. + + The timestamp is expressed as tv_sec_hi, tv_sec_lo, tv_nsec triples, + each component being an unsigned 32-bit value. Whole seconds are in + tv_sec which is a 64-bit value combined from tv_sec_hi and tv_sec_lo, + and the additional fractional part in tv_nsec as nanoseconds. Hence, + for valid timestamps tv_nsec must be in [0, 999999999]. The seconds part + may have an arbitrary offset at start. + + After receiving this event, the client should destroy the object. + + + + + + + + + This event indicates that the attempted frame copy has failed. + + After receiving this event, the client should destroy the object. + + + + + + Destroys the frame. This request can be sent at any time by the client. + + + + + + Provides information about linux-dmabuf buffer parameters that need to + be used for this frame. This event is sent once after the frame is + created if linux-dmabuf buffers are supported. + + + + + + + + + This event is sent once after all buffer events have been sent. + + The client should proceed to create a buffer of one of the supported + types, and send a "copy" request. + + + + diff --git a/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs b/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs index 30d62d903c..07af5967bf 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs @@ -60,12 +60,13 @@ pub fn screenshot_for_recording(window_id: Option, pid: Option) -> Opt return crate::wayland::screenshot_display_dispatch().ok(); } if let Some(window_id) = window_id { - crate::wayland::screenshot_dispatch(window_id).ok() + pid.and_then(|pid| u32::try_from(pid).ok()) + .and_then(|pid| crate::wayland::screenshot_dispatch_with_pid(window_id, pid).ok()) } else if let Some(pid) = pid.and_then(|pid| u32::try_from(pid).ok()) { let windows = crate::wayland::list_windows_dispatch(Some(pid)); windows .first() - .and_then(|window| crate::wayland::screenshot_dispatch(window.xid).ok()) + .and_then(|window| crate::wayland::screenshot_dispatch_with_pid(window.xid, pid).ok()) } else { crate::capture::screenshot_display_bytes().ok() } diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e71a9c8f34..e56bd06670 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -594,10 +594,11 @@ impl Tool for GetWindowStateTool { modality at ACTION time: an element ax action \ (element_index/element_token → accessibility rung) or an element px \ action (x,y → pixel rung off this screenshot). capture_mode is \ - deprecated and ignored. On Wayland, where output capture cannot prove \ - the requested surface's identity, the truthful tree is returned without \ - a screenshot and `screenshot_error.code` is \ - `surface_identity_unproven`.\n\n\ + deprecated and ignored. When a Wayland compositor cannot prove the \ + requested surface's identity, the truthful tree is returned without a \ + screenshot and `screenshot_error.code` is \ + `surface_identity_unproven`. Hyprland uses compositor-owned per-toplevel \ + capture when exact PID/title/app-id correlation succeeds.\n\n\ Optional `max_elements` / `max_depth` bound the AT-SPI walk to \ mitigate context-window blow-up on Electron / large web apps \ that produce 10k+ element trees. When applied, BOTH \ @@ -719,7 +720,7 @@ impl Tool for GetWindowStateTool { // Tuple: (Option, Option, w, h, Option). let mut screenshot_error = None; let screenshot = if should_capture { - match crate::wayland::screenshot_dispatch(xid) { + match crate::wayland::screenshot_dispatch_with_pid(xid, pid) { Ok(raw) => { let orig_w = crate::capture::png_dimensions_pub(&raw) .map(|(w, _)| w) @@ -7204,7 +7205,10 @@ impl Tool for ZoomTool { // pure-Wayland sessions surface a typed "per-window capture not // supported yet" error instead of accidentally calling the // X11-only path with a foreign-toplevel id. - let png = crate::wayland::screenshot_window_dispatch(xid)?; + let png = match pid { + Some(pid) => crate::wayland::screenshot_dispatch_with_pid(xid, pid)?, + None => crate::wayland::screenshot_window_dispatch(xid)?, + }; cursor_overlay::capture_utils::crop_png_to_jpeg(&png, x1, y1, x2, y2, 500) }) .await; diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs new file mode 100644 index 0000000000..edc2920444 --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs @@ -0,0 +1,161 @@ +//! Hyprland-specific identity resolution for per-toplevel capture. +//! +//! Standard foreign-toplevel protocols do not expose process identity or an +//! opaque handle accepted by Hyprland's toplevel-export protocol. Hyprland's +//! user-owned IPC supplies that missing correlation. Capture is authorized only +//! when one mapped client owned by the requested PID matches the compositor +//! title and app-id observed on the Wayland connection. + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::process::Command; + +#[derive(Clone, Debug, Deserialize)] +struct Client { + address: String, + mapped: bool, + #[serde(default)] + hidden: bool, + pid: i64, + #[serde(default)] + title: String, + #[serde(default)] + class: String, +} + +pub fn is_session() -> bool { + std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() + && std::env::var("XDG_CURRENT_DESKTOP") + .ok() + .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("hyprland")) +} + +/// Resolve one exact Hyprland compositor address for a Wayland observation. +/// Ambiguous title/app-id matches fail closed rather than selecting a sibling. +pub fn resolve_capture_address( + window_id: u64, + target_pid: Option, + title: &str, + app_id: &str, +) -> Result { + if !is_session() { + bail!("not a Hyprland session"); + } + let target_pid = target_pid.context("Hyprland capture requires a verified target PID")?; + resolve_from_clients(&clients()?, window_id, target_pid, title, app_id) +} + +fn resolve_from_clients( + clients: &[Client], + window_id: u64, + target_pid: u32, + title: &str, + app_id: &str, +) -> Result { + let mut owned: Vec<(u64, &Client)> = clients + .iter() + .filter(|client| client.mapped && !client.hidden && client.pid == i64::from(target_pid)) + .filter_map(|client| parse_address(&client.address).map(|address| (address, client))) + .collect(); + + if let Some((address, _)) = owned.iter().find(|(address, _)| *address == window_id) { + return Ok(*address); + } + + owned.retain(|(_, client)| { + let title_matches = !title.is_empty() && client.title == title; + let app_matches = !app_id.is_empty() && client.class == app_id; + if !title.is_empty() && !app_id.is_empty() { + title_matches && app_matches + } else { + title_matches || app_matches + } + }); + + match owned.as_slice() { + [(address, _)] => Ok(*address), + [] => bail!("no mapped Hyprland client owned by PID {target_pid} matched title/app-id"), + matches => bail!( + "Hyprland capture identity is ambiguous: {} PID-owned clients matched title/app-id", + matches.len() + ), + } +} + +fn clients() -> Result> { + let binary = if std::path::Path::new("/usr/bin/hyprctl").is_file() { + "/usr/bin/hyprctl" + } else { + "hyprctl" + }; + let output = Command::new(binary) + .args(["-j", "clients"]) + .output() + .context("launch hyprctl for compositor identity")?; + if !output.status.success() || output.stdout.is_empty() { + bail!("hyprctl clients failed"); + } + serde_json::from_slice(&output.stdout).context("parse hyprctl clients JSON") +} + +fn parse_address(address: &str) -> Option { + u64::from_str_radix(address.trim_start_matches("0x"), 16).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn client(address: &str, pid: i64, title: &str, class: &str) -> Client { + Client { + address: address.to_owned(), + mapped: true, + hidden: false, + pid, + title: title.to_owned(), + class: class.to_owned(), + } + } + + #[test] + fn parses_full_hyprland_pointer_address() { + assert_eq!(parse_address("0x55b5cd9af330"), Some(0x55b5cd9af330)); + assert_eq!(parse_address("invalid"), None); + } + + #[test] + fn identity_requires_pid_title_and_app_id() { + let clients = [ + client("0x1111", 42, "Target", "fixture"), + client("0x2222", 43, "Target", "fixture"), + client("0x3333", 42, "Other", "fixture"), + ]; + assert_eq!( + resolve_from_clients(&clients, 0xff00, 42, "Target", "fixture").unwrap(), + 0x1111 + ); + } + + #[test] + fn ambiguous_pid_owned_siblings_fail_closed() { + let clients = [ + client("0x1111", 42, "Target", "fixture"), + client("0x2222", 42, "Target", "fixture"), + ]; + let error = resolve_from_clients(&clients, 0xff00, 42, "Target", "fixture") + .expect_err("duplicate identities must not be guessed"); + assert!(error.to_string().contains("ambiguous")); + } + + #[test] + fn exact_hyprland_address_wins_within_verified_pid() { + let clients = [ + client("0x1111", 42, "First", "fixture"), + client("0x2222", 42, "Second", "fixture"), + ]; + assert_eq!( + resolve_from_clients(&clients, 0x2222, 42, "stale", "stale").unwrap(), + 0x2222 + ); + } +} diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland_capture.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland_capture.rs new file mode 100644 index 0000000000..c45de78462 --- /dev/null +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland_capture.rs @@ -0,0 +1,452 @@ +//! Wayland-native capture for Hyprland. +//! +//! Two capture paths, both pure-Rust wayland-client (no grim subprocess): +//! +//! - **Per-window**: `hyprland-toplevel-export-v1` (vendored XML, bindings +//! generated by wayland-scanner). Copies the toplevel's own buffer, so a +//! background window captures *its* content even when occluded or on +//! another workspace — unlike the grim screen-region crop it replaces. +//! The protocol handle is the `hyprctl clients` window address truncated +//! to its low 32 bits (Hyprland's `getWindowFromHandle` compares +//! `ptr & 0xFFFFFFFF`). +//! +//! Frame dispatch is deadline-bounded: `copy` with `ignore_damage=0` waits +//! for window damage and would hang forever on an idle window. Captures use +//! one short-lived connection per call. +//! +//! Caveat (documented in the skill): when Hyprland's +//! `ecosystem:enforce_permissions` is enabled and screencopy is denied, +//! the compositor reports a *successful* capture containing a black +//! "permission denied" frame — there is no failed event to detect. + +use std::fs::File; +use std::os::fd::{AsFd, AsRawFd}; +use std::os::unix::fs::FileExt; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::{ + wl_buffer::WlBuffer, + wl_registry, + wl_shm::{self, WlShm}, + wl_shm_pool::WlShmPool, +}; +use wayland_client::{delegate_noop, Connection, Dispatch, EventQueue, QueueHandle, WEnum}; +// --------------------------------------------------------------------------- +// Generated bindings for hyprland-toplevel-export-v1 (vendored XML). +// +// The v2 request `capture_toplevel_with_wlr_toplevel_handle` references +// zwlr_foreign_toplevel_handle_v1, so the wlr foreign_toplevel interfaces +// must be in scope for both the __interfaces module and the outer module. +// --------------------------------------------------------------------------- +pub mod hyprland_toplevel_export { + #![allow(non_upper_case_globals, non_camel_case_types, missing_docs)] + use wayland_client; + use wayland_client::protocol::*; + use wayland_protocols_wlr::foreign_toplevel::v1::client::*; + + pub mod __interfaces { + use wayland_client::protocol::__interfaces::*; + use wayland_protocols_wlr::foreign_toplevel::v1::client::__interfaces::*; + wayland_scanner::generate_interfaces!("./protocols/hyprland-toplevel-export-v1.xml"); + } + use self::__interfaces::*; + + wayland_scanner::generate_client_code!("./protocols/hyprland-toplevel-export-v1.xml"); +} + +use hyprland_toplevel_export::{ + hyprland_toplevel_export_frame_v1::{self, HyprlandToplevelExportFrameV1}, + hyprland_toplevel_export_manager_v1::HyprlandToplevelExportManagerV1, +}; + +/// Default deadline for buffer-param negotiation and frame copy. Generous +/// enough for a busy compositor, short enough that a recording hook never +/// stalls a tool call noticeably. +const CAPTURE_DEADLINE: Duration = Duration::from_secs(4); + +// --------------------------------------------------------------------------- +// Capture state machine shared by both frame protocols. +// --------------------------------------------------------------------------- +#[derive(Debug, Default)] +struct CaptureState { + /// (format, width, height, stride) from the frame's `buffer` event. + shm_params: Option<(wl_shm::Format, u32, u32, u32)>, + buffer_done: bool, + y_invert: bool, + ready: bool, + failed: bool, +} + +/// A captured frame with tightly packed rows (`width * 4` bytes per row), +/// y-flip already applied. `format` is the wire format: pixel byte order in +/// memory is B,G,R,[A|X] for `?rgb8888` and R,G,B,[A|X] for `?bgr8888`. +pub struct RawFrame { + pub width: u32, + pub height: u32, + pub format: wl_shm::Format, + pub data: Vec, +} + +impl Dispatch for CaptureState { + fn event( + _state: &mut Self, + _proxy: &wl_registry::WlRegistry, + _event: wl_registry::Event, + _data: &GlobalListContents, + _conn: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for CaptureState { + fn event( + state: &mut Self, + _proxy: &HyprlandToplevelExportFrameV1, + event: hyprland_toplevel_export_frame_v1::Event, + _data: &(), + _conn: &Connection, + _qh: &QueueHandle, + ) { + use hyprland_toplevel_export_frame_v1::Event; + match event { + Event::Buffer { + format: WEnum::Value(format), + width, + height, + stride, + } => { + state.shm_params = Some((format, width, height, stride)); + } + Event::BufferDone => state.buffer_done = true, + Event::Flags { + flags: WEnum::Value(flags), + } => { + state.y_invert = flags.contains(hyprland_toplevel_export_frame_v1::Flags::YInvert); + } + Event::Ready { .. } => state.ready = true, + Event::Failed => state.failed = true, + // damage / linux_dmabuf / unknown-enum values are irrelevant + // for shm capture. + _ => {} + } + } +} + +delegate_noop!(CaptureState: ignore WlShm); // advertises `format` events +delegate_noop!(CaptureState: WlShmPool); // no events +delegate_noop!(CaptureState: ignore WlBuffer); // `release` event +delegate_noop!(CaptureState: HyprlandToplevelExportManagerV1); // no events + +// --------------------------------------------------------------------------- +// Deadline-bounded dispatch +// --------------------------------------------------------------------------- + +/// Dispatch events until `done(state)` or the deadline expires. Equivalent to +/// `blocking_dispatch` but with a hard timeout, so a compositor that never +/// answers (or a damage-gated copy) cannot wedge the recording hook path. +fn dispatch_until( + queue: &mut EventQueue, + state: &mut CaptureState, + deadline: Instant, + what: &str, + done: impl Fn(&CaptureState) -> bool, +) -> Result<()> { + loop { + queue + .dispatch_pending(state) + .context("wayland dispatch failed")?; + if done(state) { + return Ok(()); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + bail!("wayland capture timed out waiting for {what}"); + } + queue.flush().context("wayland flush failed")?; + let Some(guard) = queue.prepare_read() else { + continue; // events already queued — dispatch them + }; + let fd = guard.connection_fd().as_raw_fd(); + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32; + let n = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; + if n < 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err).context("poll on wayland fd failed"); + } + if n == 0 { + bail!("wayland capture timed out waiting for {what}"); + } + match guard.read() { + Ok(_) => {} + // Another reader (none in practice — one connection per capture) + // or a spurious wakeup; loop and retry. + Err(wayland_client::backend::WaylandError::Io(e)) + if e.kind() == std::io::ErrorKind::WouldBlock => {} + Err(e) => return Err(e).context("wayland read failed"), + } + } +} + +// --------------------------------------------------------------------------- +// wl_shm helpers +// --------------------------------------------------------------------------- + +/// Anonymous shared-memory file of `len` bytes (memfd, CLOEXEC). +fn create_shm_file(len: u64) -> Result { + use std::os::fd::FromRawFd; + let name = std::ffi::CString::new("cua-driver-capture").unwrap(); + let fd = unsafe { libc::memfd_create(name.as_ptr(), libc::MFD_CLOEXEC) }; + if fd < 0 { + return Err(std::io::Error::last_os_error()).context("memfd_create failed"); + } + let file = unsafe { File::from_raw_fd(fd) }; + file.set_len(len).context("ftruncate on memfd failed")?; + Ok(file) +} + +struct ShmBuffer { + file: File, + pool: WlShmPool, + buffer: WlBuffer, +} + +impl ShmBuffer { + fn create( + shm: &WlShm, + qh: &QueueHandle, + params: (wl_shm::Format, u32, u32, u32), + ) -> Result { + let (format, width, height, stride) = params; + let len = stride as u64 * height as u64; + let file = create_shm_file(len)?; + let pool = shm.create_pool(file.as_fd(), len as i32, qh, ()); + let buffer = pool.create_buffer( + 0, + width as i32, + height as i32, + stride as i32, + format, + qh, + (), + ); + Ok(ShmBuffer { file, pool, buffer }) + } + + fn destroy(self) { + self.buffer.destroy(); + self.pool.destroy(); + } +} + +/// Copy shm rows into a tightly packed buffer, dropping stride padding and +/// un-flipping when the compositor reported y_invert. +fn pack_rows(raw: &[u8], width: u32, height: u32, stride: u32, y_invert: bool) -> Vec { + let row_bytes = width as usize * 4; + let mut out = Vec::with_capacity(row_bytes * height as usize); + for y in 0..height as usize { + let src_y = if y_invert { height as usize - 1 - y } else { y }; + let start = src_y * stride as usize; + out.extend_from_slice(&raw[start..start + row_bytes]); + } + out +} + +/// Convert a packed frame to RGBA8 in place semantics (returns a new Vec). +/// Returns an error for wl_shm formats outside the four common 32-bit ones. +pub fn frame_to_rgba8(frame: &RawFrame) -> Result> { + let mut rgba = frame.data.clone(); + match frame.format { + // Memory order B,G,R,[A|X] — swap to R,G,B,A. + wl_shm::Format::Argb8888 | wl_shm::Format::Xrgb8888 => { + let opaque = frame.format == wl_shm::Format::Xrgb8888; + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + if opaque { + px[3] = 0xFF; + } + } + } + // Memory order already R,G,B,[A|X]. + wl_shm::Format::Abgr8888 | wl_shm::Format::Xbgr8888 => { + if frame.format == wl_shm::Format::Xbgr8888 { + for px in rgba.chunks_exact_mut(4) { + px[3] = 0xFF; + } + } + } + other => bail!("unsupported wl_shm capture format {other:?}"), + } + Ok(rgba) +} + +/// The hyprland-toplevel-export protocol handle for a `hyprctl clients` +/// window address: the low 32 bits of the (64-bit) address. +pub fn toplevel_handle(address: u64) -> u32 { + (address & 0xFFFF_FFFF) as u32 +} + +// --------------------------------------------------------------------------- +// One-shot per-window capture (hyprland-toplevel-export-v1) +// --------------------------------------------------------------------------- + +/// Capture one frame of a Hyprland toplevel by its `hyprctl clients` address. +/// Works for occluded windows and windows on other workspaces — the +/// compositor renders the toplevel's own surface tree into the buffer. +/// +/// Fully synchronous; call from a blocking context. Buffer dimensions are +/// physical pixels at the window's render scale (a 1.5x-scaled 800x600 +/// window yields a 1200x900 frame). +pub fn capture_toplevel_frame(address: u64, overlay_cursor: bool) -> Result { + let conn = Connection::connect_to_env().context("WAYLAND_DISPLAY connect failed")?; + let (globals, mut queue) = registry_queue_init::(&conn) + .context("wl_registry global enumeration failed")?; + let qh = queue.handle(); + + let shm: WlShm = globals + .bind(&qh, 1..=1, ()) + .context("compositor lacks wl_shm")?; + let manager: HyprlandToplevelExportManagerV1 = globals + .bind(&qh, 1..=2, ()) + .context("compositor lacks hyprland_toplevel_export_manager_v1 (not Hyprland?)")?; + + let handle = toplevel_handle(address); + let mut state = CaptureState::default(); + let deadline = Instant::now() + CAPTURE_DEADLINE; + let frame = manager.capture_toplevel(overlay_cursor as i32, handle, &qh, ()); + + // Phase 1: buffer params (buffer → buffer_done). + dispatch_until( + &mut queue, + &mut state, + deadline, + "toplevel buffer params", + |s| s.buffer_done || s.failed, + )?; + if state.failed { + frame.destroy(); + bail!("toplevel export failed for window 0x{address:x} (handle 0x{handle:08x})"); + } + let Some(params) = state.shm_params else { + frame.destroy(); + bail!("compositor offered no wl_shm buffer params"); + }; + + // Phase 2: copy. ignore_damage=1 — with 0 the copy waits for window + // damage and hangs forever on an idle window. + let shm_buf = match ShmBuffer::create(&shm, &qh, params) { + Ok(b) => b, + Err(e) => { + frame.destroy(); + return Err(e); + } + }; + frame.copy(&shm_buf.buffer, 1); + let copy_result = dispatch_until( + &mut queue, + &mut state, + deadline, + "toplevel frame copy", + |s| s.ready || s.failed, + ); + + frame.destroy(); + let (format, width, height, stride) = params; + let result = (|| { + copy_result?; + if state.failed { + bail!("toplevel export copy failed for window 0x{address:x}"); + } + let mut raw = vec![0u8; stride as usize * height as usize]; + shm_buf + .file + .read_exact_at(&mut raw, 0) + .context("read from shm file failed")?; + Ok(RawFrame { + width, + height, + format, + data: pack_rows(&raw, width, height, stride, state.y_invert), + }) + })(); + shm_buf.destroy(); + result +} + +/// Per-window PNG capture for the screenshot path. Converts to RGBA and +/// encodes with the shared core encoder. +pub fn capture_toplevel_png(address: u64) -> Result> { + let frame = capture_toplevel_frame(address, false)?; + let rgba = frame_to_rgba8(&frame)?; + cua_driver_core::image_utils::encode_rgba_to_png(&rgba, frame.width, frame.height) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toplevel_handle_truncates_to_low_32_bits() { + // The protocol doc's own example: address d161e7b0 -> 3512854448. + assert_eq!(toplevel_handle(0xd161e7b0), 3512854448); + // Full 64-bit heap pointer keeps only the low word. + assert_eq!(toplevel_handle(0x56226558b1d0), 0x6558b1d0); + assert_eq!(toplevel_handle(0xFFFF_FFFF_0000_0001), 1); + } + + #[test] + fn pack_rows_drops_stride_padding() { + // 2x2 frame, stride 12 (4 bytes padding per row). + let raw: Vec = vec![ + 1, 1, 1, 1, 2, 2, 2, 2, 99, 99, 99, 99, // row 0 + pad + 3, 3, 3, 3, 4, 4, 4, 4, 99, 99, 99, 99, // row 1 + pad + ]; + let packed = pack_rows(&raw, 2, 2, 12, false); + assert_eq!(packed, vec![1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4]); + } + + #[test] + fn pack_rows_unflips_y_invert() { + let raw: Vec = vec![ + 1, 1, 1, 1, // row 0 + 2, 2, 2, 2, // row 1 + ]; + let packed = pack_rows(&raw, 1, 2, 4, true); + assert_eq!(packed, vec![2, 2, 2, 2, 1, 1, 1, 1]); + } + + #[test] + fn frame_to_rgba8_swaps_bgr_and_forces_alpha() { + let frame = RawFrame { + width: 1, + height: 1, + format: wl_shm::Format::Xrgb8888, + data: vec![0x10, 0x20, 0x30, 0x00], // B,G,R,X + }; + assert_eq!( + frame_to_rgba8(&frame).unwrap(), + vec![0x30, 0x20, 0x10, 0xFF] + ); + + let frame = RawFrame { + width: 1, + height: 1, + format: wl_shm::Format::Abgr8888, + data: vec![0x10, 0x20, 0x30, 0x77], // already R,G,B,A + }; + assert_eq!( + frame_to_rgba8(&frame).unwrap(), + vec![0x10, 0x20, 0x30, 0x77] + ); + } +} diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 3432293834..4cb75a76d0 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -8,9 +8,14 @@ //! `zwlr_virtual_pointer_v1`. Until identified per-toplevel capture is broadly //! available, window-scoped screenshots use output crops only for visible, //! compositor-attested surfaces and return a typed identity error otherwise. +//! Hyprland uses its compositor-owned `hyprland-toplevel-export-v1` protocol +//! for identified per-window capture; unresolved Hyprland identities fail +//! closed rather than presenting an output crop as that window's pixels. pub mod ext_screencopy; pub mod ext_toplevel; +pub mod hyprland; +pub mod hyprland_capture; pub mod overlay; pub mod persistent_vptr; pub(crate) mod portal; @@ -909,7 +914,16 @@ pub(crate) unsafe fn borrowed_fd(fd: i32) -> std::os::fd::OwnedFd { #[derive(Debug)] pub struct SurfaceIdentityUnproven { window_id: u64, - reason: &'static str, + reason: String, +} + +impl SurfaceIdentityUnproven { + fn new(window_id: u64, reason: impl Into) -> Self { + Self { + window_id, + reason: reason.into(), + } + } } impl std::fmt::Display for SurfaceIdentityUnproven { @@ -936,8 +950,8 @@ struct WaylandWindowCrop { height: u32, } -fn surface_identity_unproven(window_id: u64, reason: &'static str) -> anyhow::Error { - SurfaceIdentityUnproven { window_id, reason }.into() +fn surface_identity_unproven(window_id: u64, reason: impl Into) -> anyhow::Error { + SurfaceIdentityUnproven::new(window_id, reason).into() } fn attested_wayland_crop( @@ -1023,13 +1037,43 @@ fn screenshot_window_bytes_with_dispatch( x11_capture(xid) } -/// Window capture dispatcher. X11 uses its per-window capture path. Wayland -/// crops output pixels only when compositor metadata proves that the requested -/// surface is currently rendered; otherwise it fails closed. Output-level -/// capture remains available through [`screenshot_display_dispatch`]. -pub fn screenshot_dispatch(xid: u64) -> anyhow::Result> { +fn capture_hyprland_toplevel_bounded(address: u64) -> anyhow::Result> { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("hyprland-toplevel-capture".to_owned()) + .spawn(move || { + let _ = sender.send(hyprland_capture::capture_toplevel_png(address)); + })?; + receiver + .recv_timeout(std::time::Duration::from_secs(6)) + .map_err(|_| anyhow::anyhow!("Hyprland toplevel-export capture timed out"))? +} + +fn screenshot_dispatch_for_target(xid: u64, target_pid: Option) -> anyhow::Result> { + if !is_wayland() { + return crate::capture::screenshot_window_bytes(xid); + } + + if hyprland::is_session() { + let identity = identity_for(xid).ok_or_else(|| { + SurfaceIdentityUnproven::new(xid, "no Wayland title/app-id identity was cached") + })?; + let address = + hyprland::resolve_capture_address(xid, target_pid, &identity.title, &identity.app_id) + .map_err(|error| SurfaceIdentityUnproven::new(xid, error.to_string()))?; + return capture_hyprland_toplevel_bounded(address).map_err(|error| { + SurfaceIdentityUnproven::new( + xid, + format!("Hyprland toplevel-export capture failed: {error:#}"), + ) + .into() + }); + } + + // Keep v0.22's shared contract for every other compositor: output pixels + // are cropped only from compositor-attested, currently visible geometry. screenshot_window_bytes_with_dispatch( - is_wayland(), + true, xid, wayland_window_crop, screenshot_display_dispatch, @@ -1037,6 +1081,16 @@ pub fn screenshot_dispatch(xid: u64) -> anyhow::Result> { ) } +/// Window capture dispatcher for callers that do not carry process identity. +pub fn screenshot_dispatch(xid: u64) -> anyhow::Result> { + screenshot_dispatch_for_target(xid, None) +} + +/// Window capture dispatcher with the PID proven by the public tool target. +pub fn screenshot_dispatch_with_pid(xid: u64, pid: u32) -> anyhow::Result> { + screenshot_dispatch_for_target(xid, Some(pid)) +} + fn crop_png_to_rect( output_png: &[u8], rect_x: i32, @@ -1135,13 +1189,7 @@ fn checked_shell_helper_capture( /// point for callers outside `get_window_state`; it shares the same fail-closed /// Wayland contract as [`screenshot_dispatch`]. pub fn screenshot_window_dispatch(xid: u64) -> anyhow::Result> { - screenshot_window_bytes_with_dispatch( - is_wayland(), - xid, - wayland_window_crop, - screenshot_display_dispatch, - crate::capture::screenshot_window_bytes, - ) + screenshot_dispatch(xid) } // ── Input session helper ───────────────────────────────────────────────────── From e65a101dd428b1e73af4449a89c4009dbc9c45e7 Mon Sep 17 00:00:00 2001 From: Rodri Mora <5622210+RodriMora@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:52:53 +0200 Subject: [PATCH 100/117] test(cua-driver): add Hyprland desktop harness --- .../scripts/tests/test_linux_e2e_desktop.py | 125 +++++++++ libs/cua-driver/docs/test-harnesses-guide.md | 19 +- .../rust/crates/cua-driver-testkit/src/e2e.rs | 3 + .../crates/cua-driver-testkit/src/observer.rs | 254 +++++++++++++++++- .../crates/cua-driver-testkit/src/sentinel.rs | 139 ++++++++-- .../cua-driver/tests/capture_contract_test.rs | 254 ++++++++++++++++++ scripts/ci/README.md | 22 +- scripts/ci/linux/run-rust-e2e-desktop.sh | 52 +++- 8 files changed, 823 insertions(+), 45 deletions(-) create mode 100644 .github/scripts/tests/test_linux_e2e_desktop.py diff --git a/.github/scripts/tests/test_linux_e2e_desktop.py b/.github/scripts/tests/test_linux_e2e_desktop.py new file mode 100644 index 0000000000..6c13e93b1d --- /dev/null +++ b/.github/scripts/tests/test_linux_e2e_desktop.py @@ -0,0 +1,125 @@ +"""Contract tests for the representative Linux desktop wrapper.""" + +import os +from pathlib import Path +import subprocess +import tempfile +import textwrap +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +RUNNER = REPO_ROOT / "scripts/ci/linux/run-rust-e2e-desktop.sh" + + +class TestLinuxE2eDesktop(unittest.TestCase): + def make_executable(self, path: Path, body: str) -> None: + path.write_text(textwrap.dedent(body)) + path.chmod(0o755) + + def hyprland_environment(self, directory: Path) -> dict[str, str]: + hyprctl = directory / "hyprctl" + self.make_executable( + hyprctl, + """\ + #!/usr/bin/env bash + set -euo pipefail + printf '%s\\n' "$*" >> "${HYPRCTL_CALLS}" + if [[ "$*" == "-j monitors" ]]; then + printf '[{"name":"HDMI-A-1","focused":true}]\\n' + else + printf '{}\\n' + fi + """, + ) + delegated = directory / "delegated.sh" + self.make_executable( + delegated, + """\ + #!/usr/bin/env bash + set -euo pipefail + printf 'session=%s\\n' "${CUA_E2E_WAYLAND_SESSION:-}" + printf 'compositor=%s\\n' "${CUA_E2E_COMPOSITOR:-}" + printf 'inputs=%s\\n' "${CUA_E2E_INPUT_BACKENDS:-}" + printf 'wayland=%s\\n' "${CUA_DRIVER_RS_ENABLE_WAYLAND:-}" + printf 'recording_output=%s\\n' "${CUA_WAYLAND_RECORDING_OUTPUT:-}" + printf 'args=%s\\n' "$*" + """, + ) + env = os.environ.copy() + env.update( + { + "PATH": f"{directory}:{env['PATH']}", + "XDG_SESSION_TYPE": "wayland", + "XDG_CURRENT_DESKTOP": "Hyprland", + "HYPRLAND_INSTANCE_SIGNATURE": "test-instance", + "CUA_E2E_HARNESS_FILTER": "electron", + "CUA_E2E_DESKTOP_RUNNER": str(delegated), + "HYPRCTL_CALLS": str(directory / "hyprctl-calls.txt"), + } + ) + return env + + def test_hyprland_mode_preflights_ipc_and_delegates_exact_environment(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + directory = Path(raw_directory) + env = self.hyprland_environment(directory) + result = subprocess.run( + [RUNNER, "hyprland", "--no-build"], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.splitlines(), + [ + "session=hyprland", + "compositor=hyprland", + "inputs=atspi,wlr-virtual-pointer", + "wayland=1", + "recording_output=HDMI-A-1", + "args=--no-build", + ], + ) + self.assertEqual( + (directory / "hyprctl-calls.txt").read_text().splitlines(), + ["-j version", "-j clients", "-j monitors"], + ) + + def test_hyprland_mode_refuses_a_non_hyprland_session(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + directory = Path(raw_directory) + env = self.hyprland_environment(directory) + env["XDG_CURRENT_DESKTOP"] = "GNOME" + result = subprocess.run( + [RUNNER, "hyprland"], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("does not identify Hyprland", result.stderr) + self.assertFalse((directory / "hyprctl-calls.txt").exists()) + + def test_usage_advertises_hyprland(self) -> None: + result = subprocess.run( + [RUNNER, "unknown"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("{gnome|kde|hyprland|xorg}", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/libs/cua-driver/docs/test-harnesses-guide.md b/libs/cua-driver/docs/test-harnesses-guide.md index 6a4fed73a1..5c6b6564aa 100644 --- a/libs/cua-driver/docs/test-harnesses-guide.md +++ b/libs/cua-driver/docs/test-harnesses-guide.md @@ -233,8 +233,15 @@ Wayland results are compositor-specific. The hosted lane uses Sway to prove wlroots protocols. GNOME requires the optional WinRects Shell helper for authoritative frame and buffer geometry, observation, capture, and verified target activation. A portal/libei grant persists until the user revokes it, so -subsequent driver processes do not reopen the consent dialog. -KDE requires a future target-addressable KWin adapter; portal availability by +subsequent driver processes do not reopen the consent dialog. Hyprland +representative runs use `hyprctl` only as an out-of-band test oracle for exact +focus, active-workspace visibility, and fullscreen sentinel posture; product +capture identity remains owned by the driver's Hyprland adapter. The desktop +wrapper rejects a Hyprland selector unless it can query the active compositor. +Its capture catalog also launches an actual XWayland Electron fixture, moves +that exact client to an inactive workspace, and requires target-specific pixels +without any focus or active-workspace change. KDE requires a future +target-addressable KWin adapter; portal availability by itself is not evidence that input can be sent safely to a named window. Standard Wayland does not expose the physical pointer position, so canonical Wayland rows do not claim the real-cursor preservation oracle. Focus, full @@ -399,9 +406,11 @@ hierarchy: 3. **Close representative-desktop gaps.** Hosted Sway passes the complete Electron, Tauri, GTK3, capture, and desktop-scope catalogs. A real GNOME 46 session passes GTK3, capture, and desktop scope, but still needs the shared - renderer catalog and portal-video parity. Plasma 6 still needs a verified - KWin activation adapter and its first accepted behavioral lane. Issue `#1922` - tracks the grouped backend work. + renderer catalog and portal-video parity. Hyprland has a representative + desktop selector and compositor-backed observer but still needs its first + accepted complete-matrix run on an exact candidate SHA. Plasma 6 still needs + a verified KWin activation adapter and its first accepted behavioral lane. + Issue `#1922` tracks the grouped backend work. 4. **Add representative toolkit surfaces.** GTK4, Qt5/Qt6, VTE, VCL, and GL canvases remain optional real-app gaps; shared Electron/Tauri coverage does not substitute for those native stacks. diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs index eb07f5c804..e7fa15713b 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs @@ -135,6 +135,7 @@ pub enum DriverRoute { LinuxXTest, LinuxLibei, LinuxWaylandVirtualPointer, + LinuxHyprlandToplevelExport, LinuxCuaCompositorInject, Cdp, Composite, @@ -439,6 +440,8 @@ fn compositor_from_env() -> Option { .to_ascii_lowercase(); if desktop.contains("sway") { Some("sway".to_owned()) + } else if desktop.contains("hyprland") { + Some("hyprland".to_owned()) } else if desktop.contains("gnome") { Some("gnome-mutter".to_owned()) } else if desktop.contains("kde") || desktop.contains("plasma") { diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/observer.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/observer.rs index 5960943065..f4c7849784 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/observer.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/observer.rs @@ -893,6 +893,7 @@ pub mod macos { #[cfg(target_os = "linux")] pub mod linux { + use std::collections::HashSet; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -915,6 +916,7 @@ pub mod linux { enum SessionKind { X11, Sway, + Hyprland, Gnome, CuaCompositor, Missing, @@ -938,6 +940,8 @@ pub mod linux { SessionKind::CuaCompositor } else if sway_available() { SessionKind::Sway + } else if hyprland_available() { + SessionKind::Hyprland } else if gnome_windows().is_ok() { SessionKind::Gnome } else { @@ -976,14 +980,15 @@ pub mod linux { cursor: true, leaked_input: false, }, - SessionKind::Sway | SessionKind::Gnome | SessionKind::CuaCompositor => { - ObserverCapabilities { - focus: true, - z_order: true, - cursor: false, - leaked_input: false, - } - } + SessionKind::Sway + | SessionKind::Hyprland + | SessionKind::Gnome + | SessionKind::CuaCompositor => ObserverCapabilities { + focus: true, + z_order: true, + cursor: false, + leaked_input: false, + }, SessionKind::Missing => ObserverCapabilities::default(), } } @@ -992,6 +997,7 @@ pub mod linux { match self.session { SessionKind::X11 => x11_snapshot(target), SessionKind::Sway => sway_snapshot(target), + SessionKind::Hyprland => hyprland_snapshot(target), SessionKind::Gnome => gnome_snapshot(target), SessionKind::CuaCompositor => cua_compositor_snapshot(target), SessionKind::Missing => Ok(DesktopSnapshot { @@ -1019,6 +1025,7 @@ pub mod linux { let focus_identity = || match session { SessionKind::X11 => x11_focus_identity(), SessionKind::Sway => sway_focus_identity(), + SessionKind::Hyprland => hyprland_focus_identity(), SessionKind::Gnome => gnome_focus_identity(), SessionKind::CuaCompositor => cua_compositor_focus_identity(), SessionKind::Missing => Ok(None), @@ -1096,6 +1103,173 @@ pub mod linux { std::env::var_os("SWAYSOCK").is_some_and(|value| !value.is_empty()) && sway_tree().is_ok() } + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] + struct HyprlandWorkspace { + #[serde(default)] + id: i64, + } + + #[derive(Clone, Debug, Deserialize, Eq, PartialEq)] + struct HyprlandClient { + address: String, + #[serde(default)] + pid: u32, + #[serde(default)] + workspace: HyprlandWorkspace, + #[serde(default)] + size: [i64; 2], + #[serde(default)] + mapped: bool, + #[serde(default)] + hidden: bool, + #[serde(default)] + fullscreen: i64, + } + + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] + struct HyprlandMonitor { + #[serde(default, rename = "activeWorkspace")] + active_workspace: HyprlandWorkspace, + } + + fn hyprctl_json(query: &str) -> Result { + let output = Command::new("hyprctl") + .args(["-j", query]) + .output() + .map_err(|error| ObserverError::new(format!("hyprctl -j {query} failed: {error}")))?; + if !output.status.success() { + return Err(ObserverError::new(format!( + "hyprctl -j {query} exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ))); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| ObserverError::new(format!("invalid hyprctl {query} JSON: {error}"))) + } + + fn parse_hyprland_address(address: &str) -> Option { + u64::from_str_radix(address.trim().trim_start_matches("0x"), 16) + .ok() + .filter(|address| *address != 0) + } + + fn hyprland_clients() -> Result, ObserverError> { + hyprctl_json("clients") + } + + fn hyprland_active_address() -> Result, ObserverError> { + let active: serde_json::Value = hyprctl_json("activewindow")?; + Ok(active["address"].as_str().and_then(parse_hyprland_address)) + } + + fn hyprland_active_workspaces() -> Result, ObserverError> { + let monitors: Vec = hyprctl_json("monitors")?; + Ok(monitors + .into_iter() + .map(|monitor| monitor.active_workspace.id) + .filter(|workspace| *workspace != 0) + .collect()) + } + + fn select_hyprland_pid_client( + clients: &[HyprlandClient], + pid: u32, + ) -> Result, ObserverError> { + let candidates = clients + .iter() + .filter(|client| client.pid == pid && client.mapped) + .collect::>(); + match candidates.as_slice() { + [] => Ok(None), + [client] => Ok(Some(*client)), + _ => { + // Canonical fixtures can transiently own a small child window. + // Select the unique largest mapped surface, but never guess on + // equal-area siblings. + let max_area = candidates + .iter() + .map(|client| client.size[0].saturating_mul(client.size[1])) + .max() + .unwrap_or_default(); + let largest = candidates + .into_iter() + .filter(|client| client.size[0].saturating_mul(client.size[1]) == max_area) + .collect::>(); + match largest.as_slice() { + [client] => Ok(Some(*client)), + _ => Err(ObserverError::new(format!( + "Hyprland observer found ambiguous mapped clients for pid {pid}" + ))), + } + } + } + } + + pub(crate) fn hyprland_client_address(pid: u32) -> Result { + select_hyprland_pid_client(&hyprland_clients()?, pid)? + .map(|client| client.address.clone()) + .ok_or_else(|| { + ObserverError::new(format!( + "Hyprland observer found no mapped client for pid {pid}" + )) + }) + } + + fn classify_hyprland_target( + clients: &[HyprlandClient], + active_address: Option, + active_workspaces: &HashSet, + target_pid: u32, + ) -> Result { + let Some(target) = select_hyprland_pid_client(clients, target_pid)? else { + return Ok(TargetZ::NotFound); + }; + let target_address = parse_hyprland_address(&target.address); + if target_address.is_some() && target_address == active_address { + return Ok(TargetZ::Foreground); + } + if target.hidden || !active_workspaces.contains(&target.workspace.id) { + return Ok(TargetZ::Minimized); + } + let active = active_address.and_then(|address| { + clients + .iter() + .find(|client| parse_hyprland_address(&client.address) == Some(address)) + }); + if active.is_some_and(|client| { + client.workspace.id == target.workspace.id && client.fullscreen != 0 + }) { + Ok(TargetZ::BackgroundOccluded) + } else { + Ok(TargetZ::BackgroundVisible) + } + } + + fn hyprland_snapshot(target: TargetWindow) -> Result { + let clients = hyprland_clients()?; + let active = hyprland_active_address()?; + let target_z = + classify_hyprland_target(&clients, active, &hyprland_active_workspaces()?, target.pid)?; + Ok(DesktopSnapshot { + foreground: active, + input_focus: active, + target_z, + cursor_pos: None, + }) + } + + fn hyprland_focus_identity() -> Result, ObserverError> { + hyprland_active_address() + } + + fn hyprland_available() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .is_ok_and(|desktop| desktop.to_ascii_lowercase().contains("hyprland")) + && hyprland_clients().is_ok() + && hyprland_active_workspaces().is_ok() + } + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct CuaCompositorState { focused_pid: Option, @@ -1789,6 +1963,70 @@ pub mod linux { assert_eq!(classify_sway_target(&state), TargetZ::Minimized); } + fn hyprland_client( + address: &str, + pid: u32, + workspace: i64, + size: [i64; 2], + fullscreen: i64, + ) -> HyprlandClient { + HyprlandClient { + address: address.to_owned(), + pid, + workspace: HyprlandWorkspace { id: workspace }, + size, + mapped: true, + hidden: false, + fullscreen, + } + } + + #[test] + fn hyprland_fullscreen_foreground_occludes_same_workspace_target() { + let clients = vec![ + hyprland_client("0x10", 100, 1, [940, 780], 0), + hyprland_client("0x20", 200, 1, [1920, 1080], 1), + ]; + assert_eq!( + classify_hyprland_target(&clients, Some(0x20), &HashSet::from([1]), 100) + .expect("classified target"), + TargetZ::BackgroundOccluded + ); + } + + #[test] + fn hyprland_off_workspace_target_is_minimized_not_occluded() { + let clients = vec![ + hyprland_client("0x10", 100, 98, [940, 780], 0), + hyprland_client("0x20", 200, 1, [1920, 1080], 1), + ]; + assert_eq!( + classify_hyprland_target(&clients, Some(0x20), &HashSet::from([1]), 100) + .expect("classified target"), + TargetZ::Minimized + ); + } + + #[test] + fn hyprland_pid_selection_uses_unique_largest_surface_and_refuses_ties() { + let clients = vec![ + hyprland_client("0x10", 100, 1, [940, 780], 0), + hyprland_client("0x11", 100, 1, [320, 220], 0), + ]; + assert_eq!( + select_hyprland_pid_client(&clients, 100) + .expect("unique largest client") + .map(|client| client.address.as_str()), + Some("0x10") + ); + + let tied = vec![ + hyprland_client("0x10", 100, 1, [940, 780], 0), + hyprland_client("0x11", 100, 1, [940, 780], 0), + ]; + assert!(select_hyprland_pid_client(&tied, 100).is_err()); + } + #[test] fn cua_compositor_state_is_strict_and_pid_based() { assert_eq!( diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs index a902337f73..b6733b6d8d 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs @@ -249,7 +249,7 @@ impl ForegroundSentinel { reset_journal(&self.journal_path)?; #[cfg(target_os = "linux")] - set_sway_fullscreen(driver, self.target, false)?; + set_wayland_fullscreen(driver, self.target, false)?; let raised = driver.call( "bring_to_front", serde_json::json!({ @@ -264,7 +264,7 @@ impl ForegroundSentinel { )); } #[cfg(target_os = "linux")] - focus_sway_target(driver, background_target)?; + focus_wayland_target(driver, background_target)?; if is_wayland_session() { wait_for_native_focus_lost(self.target)?; } else { @@ -282,7 +282,7 @@ impl ForegroundSentinel { activate_native_foreground(driver, self.target); #[cfg(target_os = "linux")] - set_sway_fullscreen(driver, self.target, true)?; + set_wayland_fullscreen(driver, self.target, true)?; wait_for_native_focus_stable(self.target); std::thread::sleep(Duration::from_millis(150)); reset_journal(&self.journal_path)?; @@ -491,8 +491,8 @@ fn try_activate_native_foreground( return Err(response.text().to_owned()); } #[cfg(target_os = "linux")] - focus_sway_target(driver, target).map_err(|error| { - format!("could not focus foreground sentinel through Sway IPC: {error}") + focus_wayland_target(driver, target).map_err(|error| { + format!("could not focus foreground sentinel through compositor IPC: {error}") })?; #[cfg(target_os = "windows")] physically_focus_windows_sentinel(target); @@ -530,41 +530,126 @@ fn focus_macos_sentinel_contents( } #[cfg(target_os = "linux")] -fn focus_sway_target(driver: &mut impl Driver, target: TargetWindow) -> Result<(), String> { - let is_sway = is_wayland_session() - && std::env::var("CUA_E2E_WAYLAND_SESSION").is_ok_and(|session| session == "sway"); - if !is_sway { - return Ok(()); - } +fn wayland_e2e_session_is(expected: &str) -> bool { + is_wayland_session() + && std::env::var("CUA_E2E_WAYLAND_SESSION") + .is_ok_and(|session| session.eq_ignore_ascii_case(expected)) +} - let (_, con_id) = sway_tree_and_container_for_target(driver, target)?; - run_sway_container_command(con_id, &["focus"], "focus canary") +#[cfg(target_os = "linux")] +fn focus_wayland_target(driver: &mut impl Driver, target: TargetWindow) -> Result<(), String> { + if wayland_e2e_session_is("sway") { + let (_, con_id) = sway_tree_and_container_for_target(driver, target)?; + run_sway_container_command(con_id, &["focus"], "focus canary") + } else if wayland_e2e_session_is("hyprland") { + focus_hyprland_target(target) + } else { + Ok(()) + } } #[cfg(target_os = "linux")] -fn set_sway_fullscreen( +fn set_wayland_fullscreen( driver: &mut impl Driver, target: TargetWindow, enabled: bool, ) -> Result<(), String> { - let is_sway = is_wayland_session() - && std::env::var("CUA_E2E_WAYLAND_SESSION").is_ok_and(|session| session == "sway"); - if !is_sway { + if wayland_e2e_session_is("sway") { + let (tree, view_id) = sway_tree_and_container_for_target(driver, target)?; + let con_id = if enabled { + view_id + } else { + sway_fullscreen_holder_id(&tree, view_id).unwrap_or(view_id) + }; + let state = if enabled { "enable" } else { "disable" }; + run_sway_container_command(con_id, &["fullscreen", state], "fullscreen canary")?; + if !enabled { + wait_for_sway_fullscreen_cleared(view_id)?; + } + Ok(()) + } else if wayland_e2e_session_is("hyprland") { + set_hyprland_fullscreen(target, enabled) + } else { + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn focus_hyprland_target(target: TargetWindow) -> Result<(), String> { + let address = crate::observer::linux::hyprland_client_address(target.pid) + .map_err(|error| error.to_string())?; + run_hyprland_dispatch("focuswindow", &format!("address:{address}")) +} + +#[cfg(target_os = "linux")] +fn hyprland_fullscreen_state(address: &str) -> Result { + let output = Command::new("hyprctl") + .args(["-j", "clients"]) + .output() + .map_err(|error| format!("read Hyprland clients for fullscreen canary: {error}"))?; + if !output.status.success() { + return Err(format!( + "read Hyprland clients for fullscreen canary: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let clients: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("parse Hyprland clients for fullscreen canary: {error}"))?; + clients + .as_array() + .and_then(|clients| { + clients.iter().find(|client| { + client["address"] + .as_str() + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(address)) + }) + }) + .and_then(|client| client["fullscreen"].as_i64()) + .map(|state| state != 0) + .ok_or_else(|| format!("Hyprland fullscreen canary lost client {address}")) +} + +#[cfg(target_os = "linux")] +fn set_hyprland_fullscreen(target: TargetWindow, enabled: bool) -> Result<(), String> { + let address = crate::observer::linux::hyprland_client_address(target.pid) + .map_err(|error| error.to_string())?; + run_hyprland_dispatch("focuswindow", &format!("address:{address}"))?; + if hyprland_fullscreen_state(&address)? == enabled { return Ok(()); } + // Hyprland's `fullscreen 0` dispatcher toggles the focused client's + // compositor fullscreen state. Read before and after so this helper has + // set semantics rather than accidentally toggling an already-correct state. + run_hyprland_dispatch("fullscreen", "0")?; + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if hyprland_fullscreen_state(&address)? == enabled { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(format!( + "Hyprland fullscreen canary did not reach enabled={enabled} for {address}" + )); + } + std::thread::sleep(Duration::from_millis(25)); + } +} - let (tree, view_id) = sway_tree_and_container_for_target(driver, target)?; - let con_id = if enabled { - view_id +#[cfg(target_os = "linux")] +fn run_hyprland_dispatch(dispatcher: &str, argument: &str) -> Result<(), String> { + let output = Command::new("hyprctl") + .args(["dispatch", dispatcher, argument]) + .output() + .map_err(|error| format!("run Hyprland {dispatcher} {argument}: {error}"))?; + if output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "ok" { + Ok(()) } else { - sway_fullscreen_holder_id(&tree, view_id).unwrap_or(view_id) - }; - let state = if enabled { "enable" } else { "disable" }; - run_sway_container_command(con_id, &["fullscreen", state], "fullscreen canary")?; - if !enabled { - wait_for_sway_fullscreen_cleared(view_id)?; + Err(format!( + "Hyprland {dispatcher} {argument} failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )) } - Ok(()) } #[cfg(target_os = "linux")] diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/capture_contract_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/capture_contract_test.rs index 574a01adc6..11cfc583bb 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/capture_contract_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/capture_contract_test.rs @@ -19,6 +19,8 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +#[cfg(target_os = "linux")] +use base64::Engine as _; use cua_driver_testkit::e2e::{ execute_case, native_readonly_case, recording_evidence, DriverRoute, Evidence, Observation, OracleKind, Targeting, @@ -347,6 +349,128 @@ fn resolve_new_window( None } +#[cfg(target_os = "linux")] +fn web_snapshot_settled_default(driver: &mut McpDriver, pid: u32, window_id: u64) -> ToolResponse { + let arguments = serde_json::json!({ "pid": pid, "window_id": window_id }); + let mut response = driver.call("get_window_state", arguments.clone()); + for _ in 0..16 { + if response.tree_text().contains("WEB_HARNESS_MARKER_v1") { + break; + } + std::thread::sleep(Duration::from_millis(500)); + response = driver.call("get_window_state", arguments.clone()); + } + response +} + +#[cfg(target_os = "linux")] +fn hyprctl_json(query: &str) -> serde_json::Value { + let output = Command::new("hyprctl") + .args(["-j", query]) + .output() + .unwrap_or_else(|error| panic!("hyprctl -j {query} failed: {error}")); + assert!( + output.status.success(), + "hyprctl -j {query} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout) + .unwrap_or_else(|error| panic!("hyprctl -j {query} returned invalid JSON: {error}")) +} + +#[cfg(target_os = "linux")] +fn hyprland_client_for_pid(pid: u32) -> (String, i64) { + let clients = hyprctl_json("clients"); + let mut candidates = clients + .as_array() + .into_iter() + .flatten() + .filter(|client| { + client["pid"].as_u64() == Some(u64::from(pid)) + && client["mapped"].as_bool().unwrap_or(false) + }) + .collect::>(); + candidates.sort_by_key(|client| { + let size = client["size"].as_array(); + size.and_then(|size| Some(size.first()?.as_i64()? * size.get(1)?.as_i64()?)) + .unwrap_or_default() + }); + let client = candidates + .pop() + .unwrap_or_else(|| panic!("Hyprland exposed no mapped client for pid {pid}")); + let address = client["address"] + .as_str() + .filter(|address| address.starts_with("0x")) + .unwrap_or_else(|| panic!("Hyprland client for pid {pid} omitted its address")); + let workspace = client["workspace"]["id"] + .as_i64() + .unwrap_or_else(|| panic!("Hyprland client for pid {pid} omitted its workspace")); + (address.to_owned(), workspace) +} + +#[cfg(target_os = "linux")] +fn move_hyprland_client_silently(pid: u32, address: &str, workspace: i64) { + let target = format!("{workspace},address:{address}"); + let output = Command::new("hyprctl") + .args(["dispatch", "movetoworkspacesilent", &target]) + .output() + .expect("move Hyprland fixture to inactive workspace"); + assert!( + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "ok", + "Hyprland could not move {address} to workspace {workspace}: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if hyprland_client_for_pid(pid).1 == workspace { + return; + } + assert!( + Instant::now() < deadline, + "Hyprland client {address} did not reach workspace {workspace}" + ); + std::thread::sleep(Duration::from_millis(25)); + } +} + +#[cfg(target_os = "linux")] +fn response_png(response: &ToolResponse) -> Vec { + let image = response.raw["result"]["content"] + .as_array() + .and_then(|content| { + content.iter().find_map(|item| { + (item["type"].as_str() == Some("image") + && item["mimeType"].as_str() == Some("image/png")) + .then(|| item["data"].as_str()) + .flatten() + }) + }) + .expect("get_window_state returned no PNG content"); + base64::engine::general_purpose::STANDARD + .decode(image) + .expect("decode get_window_state PNG") +} + +#[cfg(target_os = "linux")] +fn assert_web_harness_pixels(png: &[u8]) { + let image = image::load_from_memory(png) + .expect("Hyprland toplevel capture is not a readable image") + .to_rgb8(); + let blue = image + .pixels() + .filter(|pixel| pixel[0] < 60 && (70..=150).contains(&pixel[1]) && pixel[2] > 170) + .count(); + let green = image + .pixels() + .filter(|pixel| pixel[0] < 70 && pixel[1] > 100 && pixel[2] < 100) + .count(); + assert!( + blue > 100 && green > 100, + "off-workspace capture omitted target-specific blue/green harness surfaces: blue={blue} green={green}" + ); +} + fn run_capture_case( action: &str, oracles: Vec, @@ -625,6 +749,136 @@ fn off_workspace_xwayland_capture_refuses_unbound_pixels() { }); } +/// Hyprland must capture the named XWayland toplevel itself after it leaves the +/// active workspace. Returning the current desktop or an unrelated app is a +/// contract failure, not a degraded success. +#[cfg(target_os = "linux")] +#[test] +#[ignore] +fn hyprland_off_workspace_xwayland_capture_is_target_bound() { + if std::env::var("CUA_E2E_WAYLAND_SESSION").as_deref() != Ok("hyprland") { + return; + } + + let case = native_readonly_case( + "electron", + "hyprland_off_workspace_xwayland_capture", + Targeting::NotApplicable, + DriverRoute::LinuxHyprlandToplevelExport, + vec![ + OracleKind::AxState, + OracleKind::Pixels, + OracleKind::Focus, + OracleKind::ZOrder, + ], + ); + execute_case(case, |evidence| { + let mut driver = test_driver("linux-electron-hyprland-off-workspace-xwayland-capture") + .expect("required capture driver did not start"); + *evidence = recording_evidence(driver.recording_dir()); + + let executable = harness_app("harness-electron", "CuaTestHarness.Electron"); + assert!( + executable.exists(), + "required Electron fixture is missing: {executable:?}" + ); + let before = harness_pids(&mut driver, "CuaTestHarness Electron"); + let user_data = tempfile::Builder::new() + .prefix("cua-e2e-hyprland-xwayland-") + .tempdir() + .expect("create XWayland fixture user data"); + driver + .reaper() + .spawn( + Command::new(&executable) + .env_remove("WAYLAND_DISPLAY") + .env("ELECTRON_OZONE_PLATFORM_HINT", "x11") + .env("CUA_E2E_USER_DATA_DIR", user_data.path()) + .args([ + "--ozone-platform=x11", + "--no-sandbox", + "--disable-gpu", + "--force-renderer-accessibility", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()), + ) + .expect("launch XWayland Electron fixture"); + let (pid, window_id) = resolve_new_window(&mut driver, "CuaTestHarness Electron", &before) + .expect("XWayland Electron fixture did not expose a driver window"); + + let settled = web_snapshot_settled_default(&mut driver, pid, window_id); + assert!( + settled.tree_text().contains("WEB_HARNESS_MARKER_v1"), + "XWayland fixture did not publish its accessibility marker" + ); + assert_web_harness_pixels(&response_png(&settled)); + + let active_workspaces = hyprctl_json("monitors") + .as_array() + .into_iter() + .flatten() + .filter_map(|monitor| monitor["activeWorkspace"]["id"].as_i64()) + .collect::>(); + let inactive_workspace = (90..=99) + .find(|workspace| !active_workspaces.contains(workspace)) + .expect("Hyprland representative session has no free inactive test workspace"); + let (address, _) = hyprland_client_for_pid(pid); + move_hyprland_client_silently(pid, &address, inactive_workspace); + + driver.start_behavior_recording(); + // Recording attachment is setup, not part of the read-only capture + // boundary. Snapshot compositor state only after it has initialized. + let active_before = hyprctl_json("activewindow")["address"].clone(); + let workspaces_before = active_workspaces; + let response = web_snapshot_settled_default(&mut driver, pid, window_id); + let active_after = hyprctl_json("activewindow")["address"].clone(); + let workspaces_after = hyprctl_json("monitors") + .as_array() + .into_iter() + .flatten() + .filter_map(|monitor| monitor["activeWorkspace"]["id"].as_i64()) + .collect::>(); + + assert!( + !response.is_error(), + "off-workspace Hyprland capture failed: {}", + response.text() + ); + assert!( + response.tree_text().contains("WEB_HARNESS_MARKER_v1"), + "off-workspace capture lost the target accessibility tree" + ); + assert_web_harness_pixels(&response_png(&response)); + assert_eq!( + active_after, active_before, + "off-workspace capture changed Hyprland focus" + ); + assert_eq!( + workspaces_after, workspaces_before, + "off-workspace capture changed active Hyprland workspaces" + ); + assert_eq!( + hyprland_client_for_pid(pid).1, + inactive_workspace, + "capture moved the XWayland target back to an active workspace" + ); + + let observation = Observation::delivered( + vec![ + OracleKind::AxState, + OracleKind::Pixels, + OracleKind::Focus, + OracleKind::ZOrder, + ], + Evidence::default(), + ); + drop(driver); + drop(user_data); + observation + }); +} + /// Capturing an occluded background window is a read-only operation: it must /// return pixels without disturbing the user's foreground desktop. #[cfg(target_os = "windows")] diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 84573c036d..4381f21952 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -85,7 +85,7 @@ and native harnesses, see | `linux/run-rust-e2e.sh` | Existing Linux X11 or Wayland desktop | no selector | | `linux/run-rust-e2e-wayland.sh` | Headless native Sway session | no selector | | `linux/run-rust-e2e-inject.sh` | Nested `cua-compositor` session | no selector | -| `linux/run-rust-e2e-desktop.sh` | Existing representative Linux desktop | no selector | +| `linux/run-rust-e2e-desktop.sh` | Existing representative GNOME, KDE, Hyprland, or Xorg desktop | environment name | | `windows/run-rust-e2e.ps1` | Windows console/RDP user session | `-RequireGui` | | `macos/run-rust-e2e.sh` | Logged-in macOS session already prepared by the maintainer wrapper | no selector | @@ -121,8 +121,24 @@ oracles for that targeted regression. Run the nested compositor wrapper through `nix develop .#cua-driver-inject-e2e`. This environment is experimental and -proves only the private compositor-owned route. Use `run-rust-e2e-desktop.sh` -for maintainer checks on representative GNOME, KDE, or real-Xorg sessions. +proves only the private compositor-owned route. Use the representative desktop +wrapper with an explicit environment name for maintainer checks: + +```bash +scripts/ci/linux/run-rust-e2e-desktop.sh gnome +scripts/ci/linux/run-rust-e2e-desktop.sh kde +scripts/ci/linux/run-rust-e2e-desktop.sh hyprland +scripts/ci/linux/run-rust-e2e-desktop.sh xorg +``` + +The Hyprland mode requires the active session's `hyprctl` IPC and uses it only +as an out-of-band test oracle for focus, workspace visibility, fullscreen +occlusion, and selection of the focused output for `wf-recorder`. Override that +video output with `CUA_WAYLAND_RECORDING_OUTPUT` when fixtures run elsewhere. +Like every representative-desktop run, the complete matrix launches, +focuses, moves, and captures fixture windows. Run it only in a disposable or +dedicated validation session, not on a personal desktop containing unrelated +windows. The GitHub-hosted Windows workflow is canonical when its strict preflight proves an interactive desktop. The workflow also accepts a runner label so maintainers diff --git a/scripts/ci/linux/run-rust-e2e-desktop.sh b/scripts/ci/linux/run-rust-e2e-desktop.sh index c581a579a3..e5a2364134 100755 --- a/scripts/ci/linux/run-rust-e2e-desktop.sh +++ b/scripts/ci/linux/run-rust-e2e-desktop.sh @@ -47,6 +47,49 @@ case "${ENVIRONMENT}" in export CUA_E2E_INPUT_BACKENDS=atspi,libei-portal export CUA_DRIVER_RS_ENABLE_WAYLAND=1 ;; + hyprland) + [[ "${XDG_SESSION_TYPE:-}" == wayland ]] || { + echo "Hyprland validation requires an active Wayland user session" >&2 + exit 2 + } + [[ "${XDG_CURRENT_DESKTOP,,}" == *hyprland* ]] || { + echo "XDG_CURRENT_DESKTOP does not identify Hyprland: ${XDG_CURRENT_DESKTOP:-}" >&2 + exit 2 + } + [[ -n "${HYPRLAND_INSTANCE_SIGNATURE:-}" ]] || { + echo "HYPRLAND_INSTANCE_SIGNATURE is unavailable in this user session" >&2 + exit 2 + } + command -v hyprctl >/dev/null || { + echo "hyprctl is required for representative Hyprland validation" >&2 + exit 2 + } + hyprctl -j version >/dev/null || { + echo "hyprctl cannot query the active Hyprland compositor" >&2 + exit 2 + } + hyprctl -j clients >/dev/null || { + echo "hyprctl cannot query Hyprland client identity" >&2 + exit 2 + } + command -v jq >/dev/null || { + echo "jq is required for representative Hyprland validation" >&2 + exit 2 + } + if [[ -z "${CUA_WAYLAND_RECORDING_OUTPUT:-}" ]]; then + CUA_WAYLAND_RECORDING_OUTPUT="$( + hyprctl -j monitors | jq -er '.[] | select(.focused) | .name' + )" || { + echo "hyprctl did not identify one focused output for video recording" >&2 + exit 2 + } + export CUA_WAYLAND_RECORDING_OUTPUT + fi + export CUA_E2E_WAYLAND_SESSION=hyprland + export CUA_E2E_COMPOSITOR=hyprland + export CUA_E2E_INPUT_BACKENDS=atspi,wlr-virtual-pointer + export CUA_DRIVER_RS_ENABLE_WAYLAND=1 + ;; xorg) [[ -n "${DISPLAY:-}" && "${XDG_SESSION_TYPE:-x11}" != wayland ]] || { echo "Real-Xorg validation requires DISPLAY in a non-Wayland session" >&2 @@ -56,7 +99,7 @@ case "${ENVIRONMENT}" in export CUA_E2E_INPUT_BACKENDS=atspi,xsend-event,xtest,mpx-uinput ;; *) - echo "Usage: run-rust-e2e-desktop.sh {gnome|kde|xorg} [--no-build]" >&2 + echo "Usage: run-rust-e2e-desktop.sh {gnome|kde|hyprland|xorg} [--no-build]" >&2 exit 2 ;; esac @@ -67,4 +110,9 @@ if [[ ",${effective_harness_filter}," == *,tauri,* && ! -e /dev/dri/renderD128 ] exit 2 fi -exec "${SCRIPT_DIR}/run-rust-e2e.sh" "$@" +DESKTOP_RUNNER="${CUA_E2E_DESKTOP_RUNNER:-${SCRIPT_DIR}/run-rust-e2e.sh}" +if [[ ! -x "${DESKTOP_RUNNER}" ]]; then + echo "Linux desktop session runner is not executable: ${DESKTOP_RUNNER}" >&2 + exit 2 +fi +exec "${DESKTOP_RUNNER}" "$@" From ec1870b7be52b1a5ee2760f973e7269dca690c66 Mon Sep 17 00:00:00 2001 From: Rodri Mora Date: Mon, 10 Aug 2026 23:59:08 +0200 Subject: [PATCH 101/117] fix(cua-driver): normalize Hyprland desktop coordinates Use compositor geometry for stable window ids, mixed-scale pointer positioning, screenshot mapping, and renderer AT-SPI bounds. Exercise WebKitGTK through its valid SHM path and expose unsupported Hyprland button semantics as a typed limitation instead of false success. Adapted the Hyprland cursor-positioning design from https://github.com/IlyasKhallouki/hypruse. Co-authored-by: Ilyas Khallouki --- libs/cua-driver/docs/test-harnesses-guide.md | 7 + .../rust/crates/cua-driver-testkit/src/e2e.rs | 75 +++- .../tests/cross_platform_behavior_test.rs | 54 ++- .../crates/platform-linux/src/atspi/native.rs | 204 +++++++--- .../crates/platform-linux/src/tools/impl_.rs | 83 +++- .../platform-linux/src/wayland/hyprland.rs | 373 +++++++++++++++++- .../crates/platform-linux/src/wayland/mod.rs | 269 +++++++++---- scripts/ci/README.md | 2 + 8 files changed, 909 insertions(+), 158 deletions(-) diff --git a/libs/cua-driver/docs/test-harnesses-guide.md b/libs/cua-driver/docs/test-harnesses-guide.md index 5c6b6564aa..f47aae17eb 100644 --- a/libs/cua-driver/docs/test-harnesses-guide.md +++ b/libs/cua-driver/docs/test-harnesses-guide.md @@ -238,6 +238,13 @@ representative runs use `hyprctl` only as an out-of-band test oracle for exact focus, active-workspace visibility, and fullscreen sentinel posture; product capture identity remains owned by the driver's Hyprland adapter. The desktop wrapper rejects a Hyprland selector unless it can query the active compositor. +For the Tauri fixture, the harness disables WebKitGTK's DMA-BUF renderer because +current WebKitGTK can commit without an explicit-sync acquire point and +Hyprland correctly terminates that client with `Missing acquire timeline`; the +SHM renderer exercises the same web content without violating the protocol. +WebKitGTK also ignores Hyprland virtual-pointer button events: element-addressed +left clicks use AT-SPI, while foreground right-click, double-click, and drag +return the typed `foreground_unavailable` limitation instead of false success. Its capture catalog also launches an actual XWayland Electron fixture, moves that exact client to an inactive workspace, and requires target-specific pixels without any focus or active-workspace change. KDE requires a future diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs index e7fa15713b..453ba1b1ab 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs @@ -301,6 +301,7 @@ pub enum OracleKind { #[serde(rename_all = "snake_case")] pub enum RefusalCode { BackgroundUnavailable, + ForegroundUnavailable, BackgroundOccluded, BackgroundUipiBlocked, BrowserRouteUnavailable, @@ -323,6 +324,7 @@ impl RefusalCode { pub fn from_driver_code(code: &str) -> Option { match code { "background_unavailable" => Some(Self::BackgroundUnavailable), + "foreground_unavailable" => Some(Self::ForegroundUnavailable), "background_occluded" => Some(Self::BackgroundOccluded), "background_uipi_blocked" => Some(Self::BackgroundUipiBlocked), "browser_route_unavailable" => Some(Self::BrowserRouteUnavailable), @@ -575,24 +577,37 @@ impl CaseSpec { return Err(format!("{}: no external oracle declared", self.cell_id)); } if let ContractExpectation::Refuse { allowed_codes } = &self.expected_behavior { - if self.delivery != Delivery::Background { - return Err(format!( - "{}: only background delivery may declare refusal", - self.cell_id - )); - } if allowed_codes.is_empty() { return Err(format!("{}: refusal has no allowed code", self.cell_id)); } - for required in [ - OracleKind::Focus, - OracleKind::ZOrder, - OracleKind::NoLeakedInput, - ] { - if !self.oracles.contains(&required) { + match self.delivery { + Delivery::Background => { + for required in [ + OracleKind::Focus, + OracleKind::ZOrder, + OracleKind::NoLeakedInput, + ] { + if !self.oracles.contains(&required) { + return Err(format!( + "{}: refusal is missing {:?} oracle", + self.cell_id, required + )); + } + } + } + Delivery::Foreground + if allowed_codes.as_slice() == [RefusalCode::ForegroundUnavailable] + && self.oracles.contains(&OracleKind::FixtureState) => {} + Delivery::Foreground => { return Err(format!( - "{}: refusal is missing {:?} oracle", - self.cell_id, required + "{}: foreground refusal must declare only foreground_unavailable and the fixture-state oracle", + self.cell_id + )); + } + Delivery::NotApplicable => { + return Err(format!( + "{}: not-applicable delivery may not declare refusal", + self.cell_id )); } } @@ -1367,7 +1382,10 @@ impl CatalogPolicy { fn case_requires_action_turn(case: &CaseSpec) -> bool { !matches!( case.driver_route, - DriverRoute::CaptureScopeGate | DriverRoute::AxRead | DriverRoute::WindowState + DriverRoute::CaptureScopeGate + | DriverRoute::AxRead + | DriverRoute::WindowState + | DriverRoute::LinuxHyprlandToplevelExport ) && case.action != "screenshot" } @@ -1811,6 +1829,21 @@ mod tests { assert!(result.message.contains("required delivery was refused")); } + #[test] + fn foreground_limitation_requires_its_typed_code_and_fixture_oracle() { + let mut case = delivered_case("foreground-limitation"); + case.delivery = Delivery::Foreground; + case.expected_behavior = ContractExpectation::Refuse { + allowed_codes: vec![RefusalCode::ForegroundUnavailable], + }; + assert!(case.validate().is_ok()); + + case.expected_behavior = ContractExpectation::Refuse { + allowed_codes: vec![RefusalCode::BackgroundUnavailable], + }; + assert!(case.validate().is_err()); + } + #[test] fn refusal_requires_explicit_code_and_side_effect_oracles() { let case = delivered_case("expected-refusal") @@ -1990,6 +2023,18 @@ mod tests { .expect("readonly cells have no action turn to capture"); } + #[test] + fn strict_hyprland_capture_does_not_invent_an_action_turn() { + let case = native_readonly_case( + "electron", + "hyprland_off_workspace_xwayland_capture", + Targeting::NotApplicable, + DriverRoute::LinuxHyprlandToplevelExport, + vec![OracleKind::AxState, OracleKind::Pixels], + ); + assert!(!case_requires_action_turn(&case)); + } + #[test] fn strict_background_screenshot_does_not_invent_an_action_turn() { let case = CaseSpec::delivered( diff --git a/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs b/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs index dc82e03304..c2ffe6d494 100644 --- a/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs +++ b/libs/cua-driver/rust/crates/cua-driver/tests/cross_platform_behavior_test.rs @@ -318,6 +318,13 @@ fn launch_host_with_evidence(spec: &HostSpec, scenario: &str, evidence: &mut Evi cdp_port.expect("Electron CDP port").to_string(), ); } + "tauri" if std::env::var("CUA_E2E_WAYLAND_SESSION").as_deref() == Ok("hyprland") => { + // Current WebKitGTK can commit a DMA-BUF before assigning the + // explicit-sync acquire point. Hyprland correctly rejects that + // protocol violation ("Missing acquire timeline"), so exercise + // the same WebKit content through its supported SHM renderer. + command.env("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + } "webview2" => { command.env( "CUA_WEBVIEW_CDP_PORT", @@ -481,7 +488,23 @@ fn screenshot_scale(state: &ToolResponse) -> f64 { .and_then(|elements| { elements .iter() - .find(|element| element["role"].as_str() == Some("AXWindow")) + .find(|element| { + element["role"].as_str() == Some("AXWindow") + || (cfg!(target_os = "linux") + && element["depth"].as_u64() == Some(0) + && element["role"].as_str() == Some("frame")) + }) + .or_else(|| { + cfg!(target_os = "linux").then(|| { + elements.iter().max_by(|left, right| { + let area = |element: &serde_json::Value| { + element["frame"]["w"].as_f64().unwrap_or(0.0) + * element["frame"]["h"].as_f64().unwrap_or(0.0) + }; + area(left).total_cmp(&area(right)) + }) + })? + }) }) .and_then(|window| window["frame"]["w"].as_f64()) .unwrap_or(0.0); @@ -763,12 +786,15 @@ fn unverified_background_protocol_oracle( } fn background_refusal_code(response: &ToolResponse, delivery: &str) -> Option { - if delivery != "background" || !response.is_error() { + if !response.is_error() { return None; } - response.structured()["code"] + let code = response.structured()["code"] .as_str() - .and_then(RefusalCode::from_driver_code) + .and_then(RefusalCode::from_driver_code)?; + ((delivery == "background" && code != RefusalCode::ForegroundUnavailable) + || (delivery == "foreground" && code == RefusalCode::ForegroundUnavailable)) + .then_some(code) } fn run_text_action(fixture: &mut Fixture, addressing: &str, delivery: &str) -> Observation { @@ -1204,6 +1230,26 @@ fn shared_case(spec: &HostSpec, action: &str, addressing: &str, delivery: &str) } _ => Vec::new(), } + } else if cfg!(target_os = "linux") + && spec.name == "tauri" + && std::env::var("CUA_E2E_WAYLAND_SESSION").as_deref() == Ok("hyprland") + && delivery_kind == Delivery::Foreground + && matches!(action, "right_click" | "double_click" | "drag") + { + vec![RefusalCode::ForegroundUnavailable] + } else if cfg!(target_os = "linux") + && cua_driver_testkit::e2e::DisplayServer::current() + == cua_driver_testkit::e2e::DisplayServer::Wayland + && delivery_kind == Delivery::Background + && (targeting == Targeting::Px + || matches!(action, "right_click" | "double_click" | "scroll")) + && std::env::var_os("CUA_INJECT_SOCKET").is_none() + { + // A compositor-global pointer cannot truthfully deliver to an occluded, + // unfocused Wayland surface. Pixel-addressed background input therefore + // fails closed unless the nested compositor's target-local injection + // socket is available. + vec![RefusalCode::BackgroundUnavailable] } else if cfg!(target_os = "linux") && spec.name == "electron" && delivery_kind == Delivery::Background diff --git a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs index 01dfc99795..84a3cd6a94 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs @@ -2400,53 +2400,22 @@ pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> let visited = collect_visited(conn, pid) .await? .ok_or_else(|| anyhow!("no AT-SPI application for pid {pid}"))?; - let web_document_origin = web_document_origin_for_visited(&visited, pid) - .await - .unwrap_or((0, 0)); - let action_nodes: Vec<&Visited> = visited.iter().filter(|v| is_indexable(v)).collect(); - let target = action_nodes - .get(idx) - .ok_or_else(|| anyhow!("element {idx} not found"))?; - if !target.has_component { - return Err(anyhow!("element {idx} exposes no Component interface")); - } - let comp = target - .acc - .proxies() - .await - .map_err(|e| anyhow!("interface proxies unavailable: {e}"))? - .component() + let xid = if crate::wayland::is_wayland() { + crate::wayland::hyprland::window_for_pid(pid) + .map(|window| window.address) + .or_else(|| { + crate::wayland::sway_ipc::window_for_pid(pid).map(|window| window.id) + }) + .unwrap_or(0) + } else { + entry_find_window_xid(pid).await.unwrap_or(0) + }; + element_bounds_for_visited(&visited, pid, xid) .await - .map_err(|e| anyhow!("Component unavailable: {e}"))?; - // Prefer WINDOW coords + a deterministic screen offset — fixes GTK4, - // whose CoordType::Screen collapses every element to (0,0). Fall back to - // Screen on Wayland / when no X11 window resolves (offset is None). - match window_to_screen_offset(pid, 0, None) { - Some((ox, oy)) => { - let (x, y, w, h) = comp - .get_extents(CoordType::Window) - .await - .map_err(|e| anyhow!("getExtents failed: {e}"))?; - let (document_x, document_y) = if target.in_web_doc { - web_document_origin - } else { - (0, 0) - }; - Ok(( - x + ox + document_x, - y + oy + document_y, - w.max(0) as u32, - h.max(0) as u32, - )) - } - None => { - let (x, y, w, h) = comp - .get_extents(CoordType::Screen) - .await - .map_err(|e| anyhow!("getExtents failed: {e}"))?; - Ok((x, y, w.max(0) as u32, h.max(0) as u32)) - } - } + .into_iter() + .find(|(element_index, _, _, _, _)| *element_index == idx) + .map(|(_, x, y, width, height)| (x, y, width, height)) + .ok_or_else(|| anyhow!("element {idx} exposes no usable Component bounds")) }, || { Err(anyhow!( @@ -2583,6 +2552,9 @@ fn authoritative_wayland_origin(pid: u32, xid: u64, title: Option<&str>) -> Opti return None; } crate::wayland::inject_accessibility_offset(pid) + .or_else(|| { + crate::wayland::hyprland::window_for_pid(pid).map(|window| (window.x, window.y)) + }) .or_else(|| crate::wayland::sway_ipc::window_origin_for_pid(pid)) .or_else(|| { (xid != 0) @@ -2601,6 +2573,11 @@ fn authoritative_wayland_origin(pid: u32, xid: u64, title: Option<&str>) -> Opti .flatten() }) .or_else(|| crate::wayland::shell_helper::window_origin_for_pid(pid)) + .or_else(|| { + title + .and_then(crate::wayland::hyprland::window_for_title) + .map(|window| (window.x, window.y)) + }) .or_else(|| title.and_then(crate::wayland::sway_ipc::window_origin_for_title)) } @@ -2634,6 +2611,20 @@ fn combine_wayland_content_offsets( /// Compositor decorations and toolkit document offsets are independent and /// therefore additive: choosing one or the other leaves WebKit controls one /// title bar away from the pixels shown to the caller. +async fn web_document_extent_for_visited(visited: &[Visited<'_>]) -> Option<(i32, i32, i32, i32)> { + let document = visited + .iter() + .filter(|node| node.has_component) + .filter(|node| is_document_role(&node.role) || node.in_web_doc) + .min_by_key(|node| node.depth)?; + let proxies = call(document.acc.proxies()).await?.ok()?; + let component = call(proxies.component()).await?.ok()?; + match call(component.get_extents(CoordType::Window)).await { + Some(Ok(extent @ (_, _, width, height))) if width > 0 && height > 0 => Some(extent), + _ => None, + } +} + async fn web_document_origin_for_visited(visited: &[Visited<'_>], pid: u32) -> Option<(i32, i32)> { if !crate::wayland::is_wayland() { return None; @@ -2701,6 +2692,62 @@ fn screen_extent_rebase( } } +#[derive(Clone, Copy, Debug)] +struct WaylandExtentScale { + frame_x: i32, + frame_y: i32, + x: f64, + y: f64, +} + +fn wayland_extent_scale( + frame: Option<(i32, i32, i32, i32)>, + compositor: Option<(i32, i32, u32, u32)>, +) -> Option { + let ((frame_x, frame_y, frame_width, frame_height), (_, _, width, height)) = + (frame?, compositor?); + if frame_width <= 0 || frame_height <= 0 || width == 0 || height == 0 { + return None; + } + let x = f64::from(width) / f64::from(frame_width); + let y = f64::from(height) / f64::from(frame_height); + // Compositor decorations can differ from toolkit frame extents by a few + // pixels. Scale only a material, uniform mismatch, such as Chromium + // publishing physical-pixel AT-SPI extents on a fractionally scaled output. + if (x - y).abs() > 0.03 || ((0.95..=1.05).contains(&x) && (0.95..=1.05).contains(&y)) { + return None; + } + Some(WaylandExtentScale { + frame_x, + frame_y, + x, + y, + }) +} + +fn scale_wayland_extent( + x: i32, + y: i32, + width: i32, + height: i32, + document: (i32, i32), + offset: (i32, i32), + scale: WaylandExtentScale, +) -> (i32, i32, u32, u32) { + let local_x = x.saturating_add(document.0).saturating_sub(scale.frame_x); + let local_y = y.saturating_add(document.1).saturating_sub(scale.frame_y); + ( + offset + .0 + .saturating_add((f64::from(local_x) * scale.x).round() as i32), + offset + .1 + .saturating_add((f64::from(local_y) * scale.y).round() as i32), + (f64::from(width) * scale.x).round().max(0.0) as u32, + (f64::from(height) * scale.y).round().max(0.0) as u32, + ) +} + fn rebase_renderer_window_offset( mut offset: (i32, i32), frame_origin: Option<(i32, i32)>, @@ -2799,7 +2846,7 @@ async fn element_bounds_for_visited( // title-bar offset). Normalize that frame to the compositor window origin // before adding the screen offset. Native GTK reports (0,0), so this is a // no-op there. - let window_frame_origin = if offset.is_some() { + let window_frame_extent = if offset.is_some() { let frame = visited.iter().find(|node| { node.has_component && matches!( @@ -2812,7 +2859,7 @@ async fn element_bounds_for_visited( Some(Ok(proxies)) => match call(proxies.component()).await { Some(Ok(component)) => { match call(component.get_extents(CoordType::Window)).await { - Some(Ok((x, y, _, _))) => Some((x, y)), + Some(Ok(extent)) => Some(extent), _ => None, } } @@ -2826,6 +2873,23 @@ async fn element_bounds_for_visited( } else { None }; + let window_frame_origin = window_frame_extent.map(|(x, y, _, _)| (x, y)); + let compositor_geometry = crate::wayland::is_wayland() + .then(|| crate::wayland::window_geometry(xid)) + .flatten(); + let wayland_scale = wayland_extent_scale(window_frame_extent, compositor_geometry); + // Chromium can publish its native top-level in compositor-logical units + // while the renderer subtree uses device pixels. Compare the document + // frame independently so web descendants are normalized without scaling + // already-correct native controls. + let web_wayland_scale = if compositor_geometry.is_some() { + wayland_extent_scale( + web_document_extent_for_visited(visited).await, + compositor_geometry, + ) + } else { + None + }; // Add compositor decorations and the embedded document origin for web // descendants only. Electron commonly contributes zero for both; WebKitGTK // under Sway needs the sum. @@ -2888,13 +2952,23 @@ async fn element_bounds_for_visited( } else { (0, 0) }; - out.push(( - idx, - x + offset_x + document_x, - y + offset_y + document_y, - w as u32, - h as u32, - )); + let node_scale = if node.in_web_doc { + web_wayland_scale.or(wayland_scale) + } else { + wayland_scale + }; + let (screen_x, screen_y, width, height) = match (node_scale, offset) { + (Some(scale), Some(offset)) => { + scale_wayland_extent(x, y, w, h, (document_x, document_y), offset, scale) + } + _ => ( + x + offset_x + document_x, + y + offset_y + document_y, + w as u32, + h as u32, + ), + }; + out.push((idx, screen_x, screen_y, width, height)); } } out @@ -3010,7 +3084,8 @@ mod coord_tests { activation_index, before_snapshot_deadline, combine_wayland_content_offsets, is_activation_action, is_enabled_state, is_indexable_capabilities, is_passive_role, is_web_process_bus, prefer_authoritative_wayland_origin, rebase_renderer_window_offset, - screen_extent_rebase, select_click_target, ApplicationSelection, + scale_wayland_extent, screen_extent_rebase, select_click_target, wayland_extent_scale, + ApplicationSelection, }; use atspi::{State, StateSet}; use std::time::Duration; @@ -3226,6 +3301,23 @@ mod coord_tests { ); } + #[test] + fn fractionally_scaled_wayland_extents_use_compositor_logical_geometry() { + let scale = wayland_extent_scale(Some((0, 0, 1892, 2085)), Some((3207, 38, 1261, 1390))) + .expect("physical-pixel AT-SPI frame should be scaled"); + assert_eq!( + scale_wayland_extent(718, 491, 421, 56, (0, 0), (3207, 38), scale), + (3686, 365, 281, 37) + ); + } + + #[test] + fn minor_wayland_frame_decoration_mismatch_is_not_scaled() { + assert!( + wayland_extent_scale(Some((0, 0, 1260, 1380)), Some((3207, 38, 1261, 1390))).is_none() + ); + } + #[test] fn compositor_origin_wins_over_stale_accessibility_observation() { assert_eq!( diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e56bd06670..74778c9dbd 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -91,6 +91,20 @@ pub fn load_driver_config() -> DriverConfig { cfg } +fn screenshot_to_window_ratio( + compositor_width: Option, + original_capture_width: Option, + screenshot_width: u32, +) -> Option { + if screenshot_width == 0 { + return None; + } + compositor_width + .filter(|width| *width > 0) + .or(original_capture_width.filter(|width| *width > 0)) + .map(|width| width as f64 / screenshot_width as f64) +} + pub struct ResizeRegistry { ratios: std::sync::Mutex>, } @@ -914,9 +928,17 @@ impl Tool for GetWindowStateTool { if let Some((b64_opt, file_path, w, h, orig_w)) = shot_opt { if !observation_only { - if let Some(ow) = orig_w { - if w > 0 { - state.resize_registry.set_ratio(pid, ow as f64 / w as f64); + let compositor_width = crate::wayland::is_wayland() + .then(|| crate::wayland::window_geometry(xid)) + .flatten() + .map(|(_, _, width, _)| width) + .filter(|width| *width > 0); + if let Some(ratio) = screenshot_to_window_ratio(compositor_width, orig_w, w) + { + if (ratio - 1.0).abs() > f64::EPSILON { + state.resize_registry.set_ratio(pid, ratio); + } else { + state.resize_registry.clear_ratio(pid); } } else { state.resize_registry.clear_ratio(pid); @@ -983,6 +1005,19 @@ mod get_window_state_capture_tests { .as_str() .is_some_and(|reason| reason.contains("surface_identity_unproven"))); } + + #[test] + fn screenshot_coordinates_prefer_compositor_geometry_over_buffer_scale() { + assert_eq!( + screenshot_to_window_ratio(Some(1261), Some(1423), 1423), + Some(1261.0 / 1423.0) + ); + assert_eq!( + screenshot_to_window_ratio(None, Some(2536), 1567), + Some(2536.0 / 1567.0) + ); + assert_eq!(screenshot_to_window_ratio(Some(800), None, 0), None); + } } pub struct LaunchAppTool; @@ -1743,6 +1778,22 @@ fn unavailable_webkit_background( }) } +fn unavailable_webkit_hyprland_pointer(pid: u32) -> Option { + (is_webkitgtk_embedder(pid) + && crate::wayland::hyprland::is_session() + && !crate::wayland::is_inject_mode()) + .then(|| { + ToolResult::error( + "Foreground pointer delivery is unavailable: WebKitGTK ignores Hyprland's virtual-pointer button events. Use an element-addressed left click when possible; right-click, double-click, and drag require a target-local compositor input backend.", + ) + .with_structured(json!({ + "code": "foreground_unavailable", + "reason": "webkitgtk_hyprland_virtual_pointer_buttons", + "delivery_mode": "foreground" + })) + }) +} + fn unavailable_webkit_keyboard_background( pid: u32, delivery: crate::input::delivery::DeliveryMode, @@ -2603,7 +2654,10 @@ impl Tool for ClickTool { // `element_index` — the coordinate-free path already verified // working. (x,y) are screen coords here, matching the frames in // `get_window_state`. Miss → fall through to the injection paths. - if !delivery.is_foreground() && button == 1 && count == 1 { + if button == 1 + && count == 1 + && (!delivery.is_foreground() || is_webkitgtk_embedder(pid)) + { if let Ok(Some(_)) = crate::atspi::perform_action_at_screen_point(pid, xid, output_x, output_y) { @@ -4225,9 +4279,9 @@ impl Tool for ScrollTool { Err(e) => return e, }; let xid_opt: Option = match &resolved { - cua_driver_core::element_token::ResolvedElement::Element { window_id, .. } => window_id - .map(|v| v as u64) - .or_else(|| args.opt_u64("window_id")), + cua_driver_core::element_token::ResolvedElement::Element { window_id, .. } => args + .opt_u64("window_id") + .or_else(|| window_id.map(|v| v as u64)), cua_driver_core::element_token::ResolvedElement::None => args.opt_u64("window_id"), }; @@ -4586,6 +4640,11 @@ impl Tool for DoubleClickTool { Ok(r) => r, Err(e) => return e, }; + if delivery.is_foreground() { + if let Some(refusal) = unavailable_webkit_hyprland_pointer(pid) { + return refusal; + } + } let elem_idx_resolved = match &resolved { cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { Some(*element_index) @@ -4820,6 +4879,11 @@ impl Tool for RightClickTool { Ok(r) => r, Err(e) => return e, }; + if delivery.is_foreground() { + if let Some(refusal) = unavailable_webkit_hyprland_pointer(pid) { + return refusal; + } + } let elem_idx_resolved = match &resolved { cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { Some(*element_index) @@ -5100,6 +5164,11 @@ impl Tool for DragTool { if let Some(refusal) = unavailable_webkit_background(pid, delivery) { return refusal; } + if delivery.is_foreground() { + if let Some(refusal) = unavailable_webkit_hyprland_pointer(pid) { + return refusal; + } + } if let Some(refusal) = unavailable_gtk_pointer_background(pid, delivery) { return refusal; } diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs index edc2920444..93106e57e9 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs @@ -7,9 +7,17 @@ //! title and app-id observed on the Wayland connection. use anyhow::{bail, Context, Result}; +use serde::de::DeserializeOwned; use serde::Deserialize; +use std::collections::HashSet; use std::process::Command; +#[derive(Clone, Debug, Default, Deserialize)] +struct Workspace { + #[serde(default)] + id: i64, +} + #[derive(Clone, Debug, Deserialize)] struct Client { address: String, @@ -21,6 +29,54 @@ struct Client { title: String, #[serde(default)] class: String, + #[serde(default)] + at: [i32; 2], + #[serde(default)] + size: [i32; 2], + #[serde(default)] + workspace: Workspace, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct Monitor { + #[serde(default, rename = "activeWorkspace")] + active_workspace: Workspace, + #[serde(default)] + x: i32, + #[serde(default)] + y: i32, + #[serde(default)] + width: u32, + #[serde(default)] + height: u32, + #[serde(default)] + scale: f64, + #[serde(default)] + transform: u8, +} + +/// Logical compositor coordinate space accepted by Hyprland virtual pointers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputLayout { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Compositor-owned Hyprland metadata for one mapped toplevel. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Window { + pub address: u64, + pub pid: u32, + pub title: String, + pub app_id: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, + pub workspace: i64, + pub visible: bool, } pub fn is_session() -> bool { @@ -30,6 +86,198 @@ pub fn is_session() -> bool { .is_some_and(|desktop| desktop.to_ascii_lowercase().contains("hyprland")) } +fn windows_from_clients(clients: &[Client], active_workspaces: &HashSet) -> Vec { + clients + .iter() + .filter(|client| client.mapped) + .filter_map(|client| { + let address = parse_address(&client.address)?; + let pid = u32::try_from(client.pid).ok().filter(|pid| *pid != 0)?; + Some(Window { + address, + pid, + title: client.title.clone(), + app_id: client.class.clone(), + x: client.at[0], + y: client.at[1], + width: u32::try_from(client.size[0]).unwrap_or_default(), + height: u32::try_from(client.size[1]).unwrap_or_default(), + workspace: client.workspace.id, + visible: !client.hidden && active_workspaces.contains(&client.workspace.id), + }) + }) + .collect() +} + +/// Read all mapped Hyprland clients with compositor-owned process, geometry, +/// workspace, and visibility metadata. +pub fn list_windows() -> Result> { + if !is_session() { + bail!("not a Hyprland session"); + } + let monitors: Vec = hyprctl_json("monitors")?; + let active_workspaces = monitors + .into_iter() + .map(|monitor| monitor.active_workspace.id) + .filter(|workspace| *workspace != 0) + .collect::>(); + Ok(windows_from_clients(&clients()?, &active_workspaces)) +} + +/// Return the logical bounding rectangle of all Hyprland outputs. Hyprland's +/// monitor positions and client coordinates are logical, while monitor mode +/// dimensions are physical and must be divided by scale. Virtual-pointer +/// absolute coordinates are normalized across this complete layout, not one +/// arbitrarily selected `wl_output`. +pub fn output_layout() -> Result { + if !is_session() { + bail!("not a Hyprland session"); + } + let monitors: Vec = hyprctl_json("monitors")?; + layout_from_monitors(&monitors).context("Hyprland reported no valid monitor layout") +} + +/// Position the real Hyprland seat cursor in compositor-logical coordinates. +/// Hyprland's compositor dispatcher is authoritative across mixed-scale and +/// multi-monitor layouts; button and axis events still use the standard +/// wlroots virtual-pointer protocol. +pub fn move_cursor(x: i32, y: i32) -> Result<()> { + if !is_session() { + bail!("not a Hyprland session"); + } + let binary = hyprctl_binary(); + let output = Command::new(binary) + .args(["dispatch", "movecursor", &x.to_string(), &y.to_string()]) + .output() + .context("launch hyprctl dispatch movecursor")?; + if !output.status.success() || !output.stdout.starts_with(b"ok") { + bail!("hyprctl dispatch movecursor failed"); + } + Ok(()) +} + +fn layout_from_monitors(monitors: &[Monitor]) -> Option { + let rectangles = monitors.iter().filter_map(|monitor| { + if monitor.width == 0 + || monitor.height == 0 + || !monitor.scale.is_finite() + || monitor.scale <= 0.0 + { + return None; + } + let (physical_width, physical_height) = if monitor.transform % 2 == 0 { + (monitor.width, monitor.height) + } else { + (monitor.height, monitor.width) + }; + let width = (f64::from(physical_width) / monitor.scale).round() as i64; + let height = (f64::from(physical_height) / monitor.scale).round() as i64; + (width > 0 && height > 0).then_some(( + i64::from(monitor.x), + i64::from(monitor.y), + i64::from(monitor.x) + width, + i64::from(monitor.y) + height, + )) + }); + + let (min_x, min_y, max_x, max_y) = + rectangles.fold(None, |bounds: Option<(i64, i64, i64, i64)>, rect| { + Some(match bounds { + None => rect, + Some((min_x, min_y, max_x, max_y)) => ( + min_x.min(rect.0), + min_y.min(rect.1), + max_x.max(rect.2), + max_y.max(rect.3), + ), + }) + })?; + Some(OutputLayout { + x: i32::try_from(min_x).ok()?, + y: i32::try_from(min_y).ok()?, + width: u32::try_from(max_x - min_x).ok()?, + height: u32::try_from(max_y - min_y).ok()?, + }) +} + +pub fn window_for_address(address: u64) -> Option { + list_windows() + .ok()? + .into_iter() + .find(|window| window.address == address) +} + +/// Correlate an accessibility observation to one compositor client. PID is +/// mandatory; title and app-id disambiguate sibling windows, and a sole +/// PID-owned client is the final safe fallback. +pub fn matching_window<'a>( + windows: &'a [Window], + pid: u32, + title: &str, + app_id: &str, +) -> Option<&'a Window> { + let owned = windows + .iter() + .filter(|window| window.pid == pid) + .collect::>(); + let title_matches = owned + .iter() + .copied() + .filter(|window| !title.is_empty() && window.title == title) + .collect::>(); + if let [window] = title_matches.as_slice() { + return Some(*window); + } + let app_matches = owned + .iter() + .copied() + .filter(|window| !app_id.is_empty() && window.app_id == app_id) + .collect::>(); + if let [window] = app_matches.as_slice() { + return Some(*window); + } + match owned.as_slice() { + [window] => Some(*window), + _ => None, + } +} + +pub fn window_for_pid(pid: u32) -> Option { + let matching = list_windows() + .ok()? + .into_iter() + .filter(|window| window.pid == pid) + .collect::>(); + match matching.as_slice() { + [window] => Some(window.clone()), + _ => None, + } +} + +pub fn window_for_title(title: &str) -> Option { + let matching = list_windows() + .ok()? + .into_iter() + .filter(|window| !title.is_empty() && window.title == title) + .collect::>(); + match matching.as_slice() { + [window] => Some(window.clone()), + _ => None, + } +} + +pub fn window_for_app_id(app_id: &str) -> Option { + let matching = list_windows() + .ok()? + .into_iter() + .filter(|window| !app_id.is_empty() && window.app_id == app_id) + .collect::>(); + match matching.as_slice() { + [window] => Some(window.clone()), + _ => None, + } +} + /// Resolve one exact Hyprland compositor address for a Wayland observation. /// Ambiguous title/app-id matches fail closed rather than selecting a sibling. pub fn resolve_capture_address( @@ -83,19 +331,26 @@ fn resolve_from_clients( } fn clients() -> Result> { - let binary = if std::path::Path::new("/usr/bin/hyprctl").is_file() { + hyprctl_json("clients") +} + +fn hyprctl_binary() -> &'static str { + if std::path::Path::new("/usr/bin/hyprctl").is_file() { "/usr/bin/hyprctl" } else { "hyprctl" - }; - let output = Command::new(binary) - .args(["-j", "clients"]) + } +} + +fn hyprctl_json(query: &str) -> Result { + let output = Command::new(hyprctl_binary()) + .args(["-j", query]) .output() - .context("launch hyprctl for compositor identity")?; + .with_context(|| format!("launch hyprctl -j {query}"))?; if !output.status.success() || output.stdout.is_empty() { - bail!("hyprctl clients failed"); + bail!("hyprctl -j {query} failed"); } - serde_json::from_slice(&output.stdout).context("parse hyprctl clients JSON") + serde_json::from_slice(&output.stdout).with_context(|| format!("parse hyprctl {query} JSON")) } fn parse_address(address: &str) -> Option { @@ -114,6 +369,20 @@ mod tests { pid, title: title.to_owned(), class: class.to_owned(), + at: [10, 20], + size: [800, 600], + workspace: Workspace { id: 1 }, + } + } + + fn monitor(x: i32, y: i32, width: u32, height: u32, scale: f64) -> Monitor { + Monitor { + x, + y, + width, + height, + scale, + ..Monitor::default() } } @@ -158,4 +427,94 @@ mod tests { 0x2222 ); } + + #[test] + fn compositor_metadata_preserves_geometry_and_workspace_visibility() { + let mut visible = client("0x1111", 42, "Target", "fixture"); + visible.at = [-20, 30]; + visible.size = [940, 780]; + visible.workspace.id = 7; + let mut hidden = client("0x2222", 43, "Hidden", "fixture"); + hidden.workspace.id = 8; + + let windows = windows_from_clients(&[visible, hidden], &HashSet::from([7])); + assert_eq!( + windows[0], + Window { + address: 0x1111, + pid: 42, + title: "Target".to_owned(), + app_id: "fixture".to_owned(), + x: -20, + y: 30, + width: 940, + height: 780, + workspace: 7, + visible: true, + } + ); + assert!(!windows[1].visible); + } + + #[test] + fn accessibility_matching_uses_pid_then_unique_title() { + let windows = windows_from_clients( + &[ + client("0x1111", 42, "Main", "fixture"), + client("0x2222", 42, "Child", "fixture"), + client("0x3333", 43, "Main", "fixture"), + ], + &HashSet::from([1]), + ); + assert_eq!( + matching_window(&windows, 42, "Main", "fixture").map(|window| window.address), + Some(0x1111) + ); + assert!(matching_window(&windows, 42, "Unknown", "fixture").is_none()); + assert!(matching_window(&windows, 44, "Main", "fixture").is_none()); + } + + #[test] + fn malformed_process_or_size_metadata_fails_closed() { + let mut invalid_pid = client("0x1111", -1, "Bad", "fixture"); + invalid_pid.size = [800, 600]; + let mut invalid_size = client("0x2222", 42, "Target", "fixture"); + invalid_size.size = [-1, 600]; + + let windows = windows_from_clients(&[invalid_pid, invalid_size], &HashSet::from([1])); + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].width, 0); + } + + #[test] + fn output_layout_uses_logical_scaled_bounds() { + let monitors = [ + monitor(384, 288, 1920, 1080, 1.25), + monitor(1920, 0, 3840, 2160, 1.5), + ]; + assert_eq!( + layout_from_monitors(&monitors), + Some(OutputLayout { + x: 384, + y: 0, + width: 4096, + height: 1440, + }) + ); + } + + #[test] + fn output_layout_translates_negative_and_rotated_outputs() { + let mut rotated = monitor(-1080, -200, 1920, 1080, 1.0); + rotated.transform = 1; + assert_eq!( + layout_from_monitors(&[rotated, monitor(0, 0, 2560, 1440, 1.0)]), + Some(OutputLayout { + x: -1080, + y: -200, + width: 3640, + height: 1920, + }) + ); + } } diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 4cb75a76d0..17b6828422 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -605,6 +605,32 @@ impl Dispatch for State { } } +fn unique_hyprland_address( + windows: &[hyprland::Window], + used: &HashSet, + title: &str, + app_id: &str, +) -> Option { + let matches = windows + .iter() + .filter(|window| !used.contains(&window.address)) + .filter(|window| { + let title_matches = !title.is_empty() && window.title == title; + let app_matches = !app_id.is_empty() && window.app_id == app_id; + if !title.is_empty() && !app_id.is_empty() { + title_matches && app_matches + } else { + title_matches || app_matches + } + }) + .map(|window| window.address) + .collect::>(); + match matches.as_slice() { + [address] => Some(*address), + _ => None, + } +} + /// Enumerate native Wayland toplevels via wlr-foreign-toplevel-management. /// `xid` begins as the foreign-toplevel handle's connection-scoped protocol id. /// The dispatcher replaces it with a stable compositor or AT-SPI identity when @@ -628,6 +654,8 @@ pub fn list_windows() -> anyhow::Result> { queue.roundtrip(&mut state)?; } + let mut hyprland_windows = hyprland::list_windows().unwrap_or_default(); + let mut used_hyprland_addresses = HashSet::new(); let sway_windows = sway_ipc::list_windows().unwrap_or_default(); let mut used_sway_ids = HashSet::new(); let mut out = Vec::new(); @@ -640,36 +668,96 @@ pub fn list_windows() -> anyhow::Result> { } else { format!("{} [{}]", tl.title, tl.app_id) }; - let sway = sway_windows - .iter() - .find(|window| { - !used_sway_ids.contains(&window.id) - && !tl.title.is_empty() - && window.title == tl.title + let mut hyprland_address = unique_hyprland_address( + &hyprland_windows, + &used_hyprland_addresses, + &tl.title, + &tl.app_id, + ); + // A foreign-toplevel event can arrive a few milliseconds before the + // same just-mapped client appears in Hyprland IPC. Never expose the + // connection-scoped protocol object as if it were a stable window id; + // briefly retry the compositor-owned identity correlation instead. + if hyprland_address.is_none() && hyprland::is_session() { + for _ in 0..4 { + std::thread::sleep(std::time::Duration::from_millis(20)); + hyprland_windows = hyprland::list_windows().unwrap_or_default(); + hyprland_address = unique_hyprland_address( + &hyprland_windows, + &used_hyprland_addresses, + &tl.title, + &tl.app_id, + ); + if hyprland_address.is_some() { + break; + } + } + } + let hyprland = hyprland_address.and_then(|address| { + hyprland_windows + .iter() + .find(|window| window.address == address) + }); + if let Some(window) = hyprland { + used_hyprland_addresses.insert(window.address); + } + + let sway = hyprland + .is_none() + .then(|| { + sway_windows + .iter() + .find(|window| { + !used_sway_ids.contains(&window.id) + && !tl.title.is_empty() + && window.title == tl.title + }) + .or_else(|| { + sway_windows.iter().find(|window| { + !used_sway_ids.contains(&window.id) + && !tl.app_id.is_empty() + && window.app_id == tl.app_id + }) + }) }) - .or_else(|| { - sway_windows.iter().find(|window| { - !used_sway_ids.contains(&window.id) - && !tl.app_id.is_empty() - && window.app_id == tl.app_id - }) - }); - let stable_id = sway.map(|window| window.id).unwrap_or(*id as u64); + .flatten(); if let Some(window) = sway { used_sway_ids.insert(window.id); } + + let stable_id = hyprland + .map(|window| window.address) + .or_else(|| sway.map(|window| window.id)) + .unwrap_or(*id as u64); remember_identity(stable_id, tl); out.push(WindowInfo { xid: stable_id, - pid: sway.map(|window| window.pid), + pid: hyprland + .map(|window| window.pid) + .or_else(|| sway.map(|window| window.pid)), app_name: tl.app_id.clone(), title, - is_on_screen: sway.map(|window| window.visible).unwrap_or(true), + is_on_screen: hyprland + .map(|window| window.visible) + .or_else(|| sway.map(|window| window.visible)) + .unwrap_or(true), z_index: None, - x: sway.map(|window| window.x).unwrap_or(0), - y: sway.map(|window| window.y).unwrap_or(0), - width: sway.map(|window| window.width).unwrap_or(0), - height: sway.map(|window| window.height).unwrap_or(0), + x: hyprland + .map(|window| window.x) + .or_else(|| sway.map(|window| window.x)) + .unwrap_or(0), + y: hyprland + .map(|window| window.y) + .or_else(|| sway.map(|window| window.y)) + .unwrap_or(0), + width: hyprland + .map(|window| window.width) + .or_else(|| sway.map(|window| window.width)) + .unwrap_or(0), + height: hyprland + .map(|window| window.height) + .or_else(|| sway.map(|window| window.height)) + .unwrap_or(0), }); } Ok(out) @@ -1246,10 +1334,47 @@ pub struct VptrSession { state: State, pub seat: WlSeat, pub vptr: ZwlrVirtualPointerV1, + pub output_x: i32, + pub output_y: i32, pub output_w: u32, pub output_h: u32, } +impl VptrSession { + fn absolute_point(&self, x: i32, y: i32) -> (u32, u32) { + let px = (i64::from(x) - i64::from(self.output_x)) + .clamp(0, i64::from(self.output_w.saturating_sub(1))) as u32; + let py = (i64::from(y) - i64::from(self.output_y)) + .clamp(0, i64::from(self.output_h.saturating_sub(1))) as u32; + (px, py) + } + + fn compositor_point(&self, x: u32, y: u32) -> (i32, i32) { + ( + self.output_x.saturating_add_unsigned(x), + self.output_y.saturating_add_unsigned(y), + ) + } + + fn position_pointer(&mut self, x: i32, y: i32) -> anyhow::Result<(i32, i32)> { + let (px, py) = self.absolute_point(x, y); + let (global_x, global_y) = self.compositor_point(px, py); + if hyprland::is_session() { + // Hyprland has known multi-output normalization defects for + // virtual-pointer absolute motion. Its compositor dispatcher uses + // the exact same global logical coordinates as client geometry; + // keep virtual-pointer for the subsequent button/axis event only. + hyprland::move_cursor(global_x, global_y)?; + } else { + self.vptr + .motion_absolute(event_time_ms(), px, py, self.output_w, self.output_h); + self.vptr.frame(); + self.queue.roundtrip(&mut self.state)?; + } + Ok((global_x, global_y)) + } +} + /// Bind manager + seat + virtual-pointer + first output, optionally activate a /// foreign-toplevel by `window_id` so the synthesised events land on it, and /// return the live session that scroll / drag / click reuse. Wayland forbids a @@ -1316,13 +1441,25 @@ pub fn open_vptr_session(activate_window_id: Option) -> anyhow::Result 0 { std::thread::sleep(std::time::Duration::from_millis(80)); } - sess.vptr.motion_absolute(event_time_ms(), px, py, w, h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; - std::thread::sleep(std::time::Duration::from_millis(15)); + std::thread::sleep(std::time::Duration::from_millis(20)); sess.vptr.button(event_time_ms(), btn, ButtonState::Pressed); sess.vptr.frame(); sess.queue.roundtrip(&mut sess.state)?; @@ -1546,7 +1679,7 @@ fn click_vptr( } // Keep the synthetic-cursor registry in sync with the warp we just // performed so a subsequent `get_cursor_position` reflects reality. - record_synth_cursor(px as i32, py as i32); + record_synth_cursor(global_x, global_y); sess.vptr.destroy(); sess.queue.roundtrip(&mut sess.state)?; Ok(()) @@ -1572,12 +1705,20 @@ pub fn window_local_to_output(window_id: u64, x: i32, y: i32) -> (i32, i32) { /// object ID came from an earlier Wayland connection. Protocol object IDs are /// connection-local, so direct equality is only a fast path. pub fn window_geometry(window_id: u64) -> Option<(i32, i32, u32, u32)> { + if let Some(window) = hyprland::window_for_address(window_id) { + return Some((window.x, window.y, window.width, window.height)); + } if let Some(window) = sway_ipc::window_for_id(window_id) { return Some((window.x, window.y, window.width, window.height)); } let identity = identity_for(window_id); if let Some(identity) = identity.as_ref() { + if let Some(window) = hyprland::window_for_title(&identity.title) + .or_else(|| hyprland::window_for_app_id(&identity.app_id)) + { + return Some((window.x, window.y, window.width, window.height)); + } if let Some(windows) = sway_ipc::list_windows() { let title_matches = windows .iter() @@ -1682,14 +1823,9 @@ fn scroll_vptr( ) -> anyhow::Result<()> { let mut sess = open_vptr_session(window_id)?; if let Some((x, y)) = point { - let px = x.clamp(0, (sess.output_w as i32).saturating_sub(1)) as u32; - let py = y.clamp(0, (sess.output_h as i32).saturating_sub(1)) as u32; - sess.vptr - .motion_absolute(event_time_ms(), px, py, sess.output_w, sess.output_h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; - record_synth_cursor(px as i32, py as i32); - std::thread::sleep(std::time::Duration::from_millis(15)); + let (global_x, global_y) = sess.position_pointer(x, y)?; + record_synth_cursor(global_x, global_y); + std::thread::sleep(std::time::Duration::from_millis(20)); } let (axis, sign): (Axis, i32) = match direction.to_ascii_lowercase().as_str() { "up" => (Axis::VerticalScroll, -1), @@ -1699,8 +1835,10 @@ fn scroll_vptr( other => anyhow::bail!("unknown scroll direction: {other}"), }; // axis_discrete: `value` is logical units (the wayland-rs wrapper - // converts to wl_fixed internally); `discrete` is the tick count. - let value: f64 = (sign as f64) * 10.0; + // converts to wl_fixed internally); `discrete` is the tick count. Fifteen + // units matches a conventional wheel notch and advances Chromium/WebKit by + // more than one 128 px fixture viewport across two requested ticks. + let value: f64 = (sign as f64) * 15.0; for i in 0..amount.max(1) { if i > 0 { std::thread::sleep(std::time::Duration::from_millis(25)); @@ -1759,13 +1897,8 @@ pub fn move_cursor_absolute(window_id: Option, x: i32, y: i32) -> anyhow::R /// wlroots virtual-pointer implementation of [`move_cursor_absolute`]. fn move_cursor_absolute_vptr(window_id: Option, x: i32, y: i32) -> anyhow::Result<()> { let mut sess = open_vptr_session(window_id)?; - let (w, h) = (sess.output_w, sess.output_h); - let px = x.clamp(0, (w as i32).saturating_sub(1)) as u32; - let py = y.clamp(0, (h as i32).saturating_sub(1)) as u32; - sess.vptr.motion_absolute(event_time_ms(), px, py, w, h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; - record_synth_cursor(px as i32, py as i32); + let (global_x, global_y) = sess.position_pointer(x, y)?; + record_synth_cursor(global_x, global_y); sess.vptr.destroy(); sess.queue.roundtrip(&mut sess.state)?; Ok(()) @@ -1828,18 +1961,8 @@ fn drag_vptr( ) -> anyhow::Result<()> { let mut sess = open_vptr_session(window_id)?; std::thread::sleep(std::time::Duration::from_millis(40)); - let (w, h) = (sess.output_w, sess.output_h); let btn = evdev_pointer_button(button); - let clamp_xy = |x: i32, y: i32| -> (u32, u32) { - ( - x.clamp(0, w as i32 - 1) as u32, - y.clamp(0, h as i32 - 1) as u32, - ) - }; - let (fx, fy) = clamp_xy(from_x, from_y); - sess.vptr.motion_absolute(event_time_ms(), fx, fy, w, h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; + sess.position_pointer(from_x, from_y)?; std::thread::sleep(std::time::Duration::from_millis(15)); sess.vptr.button(event_time_ms(), btn, ButtonState::Pressed); sess.vptr.frame(); @@ -1849,22 +1972,16 @@ fn drag_vptr( let t = s as f64 / n as f64; let ix = (from_x as f64 + (to_x - from_x) as f64 * t).round() as i32; let iy = (from_y as f64 + (to_y - from_y) as f64 * t).round() as i32; - let (cx, cy) = clamp_xy(ix, iy); - sess.vptr.motion_absolute(event_time_ms(), cx, cy, w, h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; + sess.position_pointer(ix, iy)?; std::thread::sleep(std::time::Duration::from_millis(8)); } - let (tx, ty) = clamp_xy(to_x, to_y); - sess.vptr.motion_absolute(event_time_ms(), tx, ty, w, h); - sess.vptr.frame(); - sess.queue.roundtrip(&mut sess.state)?; + let (global_x, global_y) = sess.position_pointer(to_x, to_y)?; sess.vptr .button(event_time_ms(), btn, ButtonState::Released); sess.vptr.frame(); // Sync the synthetic-cursor registry with the drag endpoint so a // subsequent `get_cursor_position` reports where we left the pointer. - record_synth_cursor(tx as i32, ty as i32); + record_synth_cursor(global_x, global_y); sess.queue.roundtrip(&mut sess.state)?; sess.vptr.destroy(); sess.queue.roundtrip(&mut sess.state)?; @@ -3069,10 +3186,24 @@ pub fn inject_drag( fn wayland_atspi_windows(filter_pid: Option) -> Vec { let mut windows = crate::atspi::list_windows(filter_pid); // AT-SPI can retain a toolkit's default placement (commonly 120,120) - // after Sway has placed the real toplevel at another origin. Reconcile the + // after a compositor has placed the real toplevel elsewhere. Reconcile the // fallback records with compositor-owned metadata before exposing them to - // callers; element bounds already use this same authoritative Sway tree. + // callers; element bounds use these same authoritative origins. + let hyprland_windows = hyprland::list_windows().unwrap_or_default(); for window in &mut windows { + let hyprland = window.pid.and_then(|pid| { + hyprland::matching_window(&hyprland_windows, pid, &window.title, &window.app_name) + }); + if let Some(hyprland) = hyprland { + window.xid = hyprland.address; + window.x = hyprland.x; + window.y = hyprland.y; + window.width = hyprland.width; + window.height = hyprland.height; + window.is_on_screen = hyprland.visible && hyprland.width > 0 && hyprland.height > 0; + continue; + } + let sway = window .pid .and_then(sway_ipc::window_for_pid) diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 4381f21952..d2a2e8b5e2 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -135,6 +135,8 @@ The Hyprland mode requires the active session's `hyprctl` IPC and uses it only as an out-of-band test oracle for focus, workspace visibility, fullscreen occlusion, and selection of the focused output for `wf-recorder`. Override that video output with `CUA_WAYLAND_RECORDING_OUTPUT` when fixtures run elsewhere. +The Tauri row uses WebKitGTK's SHM renderer because current DMA-BUF builds can +violate explicit-sync ordering and Hyprland rejects the invalid commit. Like every representative-desktop run, the complete matrix launches, focuses, moves, and captures fixture windows. Run it only in a disposable or dedicated validation session, not on a personal desktop containing unrelated From 2f01070e4730589af34036bd9ba663477dea0b9f Mon Sep 17 00:00:00 2001 From: Rodri Mora Date: Tue, 11 Aug 2026 00:46:16 +0200 Subject: [PATCH 102/117] fix(cua-driver): complete Hyprland desktop contracts Bind embedded browser routes through compositor-attested native cardinality, normalize desktop and scroll coordinates under fractional scaling, keep the foreground sentinel out of the tiling tree, and refuse WebKitGTK background pixel clicks rather than reporting false delivery. --- .../crates/cua-driver-testkit/src/sentinel.rs | 24 ++++++ .../platform-linux/src/browser_platform.rs | 49 +++++++++++ .../crates/platform-linux/src/tools/impl_.rs | 37 +++++++-- .../platform-linux/src/wayland/hyprland.rs | 81 +++++++++++++++++-- 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs index b6733b6d8d..1afbc77830 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/sentinel.rs @@ -105,6 +105,11 @@ impl ForegroundSentinel { }; reaper.track_pid(target.pid); + #[cfg(target_os = "linux")] + if wayland_e2e_session_is("hyprland") { + prepare_hyprland_sentinel(target)?; + } + let focus_deadline = Instant::now() + Duration::from_secs(10); if is_wayland_session() { wait_for_journal(&journal_path, focus_deadline, r#""kind":"ready""#, "ready"); @@ -581,6 +586,25 @@ fn focus_hyprland_target(target: TargetWindow) -> Result<(), String> { run_hyprland_dispatch("focuswindow", &format!("address:{address}")) } +#[cfg(target_os = "linux")] +fn prepare_hyprland_sentinel(target: TargetWindow) -> Result<(), String> { + let address = crate::observer::linux::hyprland_client_address(target.pid) + .map_err(|error| error.to_string())?; + // Keep the long-lived oracle window out of Hyprland's tiling tree. If it + // consumes half the output, otherwise-correct fixture controls below that + // reduced viewport are unmapped and cannot provide representative PX + // evidence. Fullscreen canaries still temporarily expand this surface. + // User rules may initially fullscreen Electron, so clear that state before + // asking Hyprland to resize the floating sentinel. + set_hyprland_fullscreen(target, false)?; + run_hyprland_dispatch("setfloating", &format!("address:{address}"))?; + run_hyprland_dispatch( + "resizewindowpixel", + &format!("exact 960 720,address:{address}"), + )?; + set_hyprland_fullscreen(target, true) +} + #[cfg(target_os = "linux")] fn hyprland_fullscreen_state(address: &str) -> Result { let output = Command::new("hyprctl") diff --git a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs index 0816d173d8..4e09aaff9a 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs @@ -532,6 +532,41 @@ impl BrowserPlatform for LinuxBrowserPlatform { ) })?; if std::env::var_os("WAYLAND_DISPLAY").is_some() { + if crate::wayland::hyprland::is_session() { + let window = + crate::wayland::hyprland::window_for_address(window_id).ok_or_else(|| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("Hyprland window {window_id} is not mapped"), + ) + })?; + if window.pid != pid_u32 { + return Err(refusal( + BrowserRefusalCode::BrowserWrongTargetRefused, + format!("Hyprland window {window_id} is not owned by pid {pid}"), + )); + } + return Ok(NativeWindowInfo { + pid, + window_id, + title: window.title, + bounds: Rect::new( + f64::from(window.x), + f64::from(window.y), + f64::from(window.width), + f64::from(window.height), + ), + geometry_exact: true, + ownership: NativeOwnershipProof { + method: NativeOwnershipMethod::PlatformAttested, + owner_pid: pid, + detail: Some( + "Hyprland IPC stable address, pid, and compositor-logical rect" + .to_owned(), + ), + }, + }); + } if let Some(window) = crate::wayland::sway_ipc::window_for_id(window_id) { if window.pid != pid_u32 { return Err(refusal( @@ -665,6 +700,20 @@ impl BrowserPlatform for LinuxBrowserPlatform { ) })?; if std::env::var_os("WAYLAND_DISPLAY").is_some() { + if crate::wayland::hyprland::is_session() { + let owned = crate::wayland::hyprland::list_windows() + .map_err(|error| { + refusal( + BrowserRefusalCode::BrowserRouteUnavailable, + format!("could not enumerate Hyprland browser windows: {error}"), + ) + })? + .into_iter() + .filter(|window| window.pid == pid_u32) + .map(|window| window.address) + .collect::>(); + return Ok(Some(owned.len() == 1 && owned[0] == window_id)); + } let Some(windows) = crate::wayland::sway_ipc::list_windows() else { if let Some(owned) = crate::wayland::shell_helper::trusted_window_ids_for_pid(pid_u32) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index 74778c9dbd..ba4f0bd559 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -2654,9 +2654,16 @@ impl Tool for ClickTool { // `element_index` — the coordinate-free path already verified // working. (x,y) are screen coords here, matching the frames in // `get_window_state`. Miss → fall through to the injection paths. + let webkitgtk = is_webkitgtk_embedder(pid); if button == 1 && count == 1 - && (!delivery.is_foreground() || is_webkitgtk_embedder(pid)) + // Chromium's proven background AT-SPI fallback and + // WebKitGTK's proven foreground AT-SPI fallback are + // deliberately disjoint. Treating WebKitGTK background PX + // coordinates as element-local delivered clicks violated + // the compositor-global pixel contract. + && ((!delivery.is_foreground() && !webkitgtk) + || (delivery.is_foreground() && webkitgtk)) { if let Ok(Some(_)) = crate::atspi::perform_action_at_screen_point(pid, xid, output_x, output_y) @@ -4184,7 +4191,9 @@ impl Tool for SetValueTool { // ── scroll ──────────────────────────────────────────────────────────────────── -pub struct ScrollTool; +pub struct ScrollTool { + state: Arc, +} static SCROLL_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); #[async_trait] @@ -4344,7 +4353,14 @@ impl Tool for ScrollTool { args.get("x").and_then(|value| value.as_f64()), args.get("y").and_then(|value| value.as_f64()), ) { - (Some(x), Some(y)) => Some((x, y)), + (Some(x), Some(y)) => { + // Pixel targets are expressed in the latest screenshot's + // coordinate space. Apply the same buffer-to-window ratio as + // click/drag so fractional-scale Wayland captures land on the + // intended logical surface point rather than below it. + let ratio = self.state.resize_registry.ratio(pid).unwrap_or(1.0); + Some((x * ratio, y * ratio)) + } (None, None) => None, _ => return ToolResult::error("Pass both x and y to pixel-target scroll."), }; @@ -6429,7 +6445,13 @@ impl Tool for GetDesktopStateTool { // Only fall back to the X11 root-window geometry off Wayland, so // the X11 / XWayland path is unchanged. See #2017 / Sway testing. let (screen_w, screen_h) = if crate::wayland::is_wayland() { - (shot_w, shot_h) + // Window and input coordinates are compositor-logical on + // Hyprland. Preserve the native-resolution PNG, but report the + // uniquely matching output's logical dimensions so callers can + // map coordinates using screenshot/logical scale just as they + // do for fractional-scale window captures. + crate::wayland::hyprland::logical_output_size_for_capture(shot_w, shot_h) + .unwrap_or((shot_w, shot_h)) } else { x11_screen_size()? }; @@ -8058,7 +8080,12 @@ pub fn build_registry_with_provider( &pid_window_candidates, )); r.register(pid_window_guarded(SetValueTool, &pid_window_candidates)); - r.register(pid_window_guarded(ScrollTool, &pid_window_candidates)); + r.register(pid_window_guarded( + ScrollTool { + state: state.clone(), + }, + &pid_window_candidates, + )); cua_driver_core::clipboard::register_clipboard_tools( &mut r, Arc::new(crate::clipboard::LinuxClipboard::new()), diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs index 93106e57e9..6019f70df6 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs @@ -137,6 +137,17 @@ pub fn output_layout() -> Result { layout_from_monitors(&monitors).context("Hyprland reported no valid monitor layout") } +/// Map one physical output capture to Hyprland's logical coordinate size. +/// Returns `None` when dimensions do not identify exactly one output, so callers +/// never guess on mirrored or otherwise ambiguous layouts. +pub fn logical_output_size_for_capture(width: u32, height: u32) -> Option<(u32, u32)> { + if !is_session() { + return None; + } + let monitors: Vec = hyprctl_json("monitors").ok()?; + logical_output_size_from_monitors(&monitors, width, height) +} + /// Position the real Hyprland seat cursor in compositor-logical coordinates. /// Hyprland's compositor dispatcher is authoritative across mixed-scale and /// multi-monitor layouts; button and axis events still use the standard @@ -156,6 +167,39 @@ pub fn move_cursor(x: i32, y: i32) -> Result<()> { Ok(()) } +fn monitor_physical_and_logical_size(monitor: &Monitor) -> Option<((u32, u32), (u32, u32))> { + if monitor.width == 0 + || monitor.height == 0 + || !monitor.scale.is_finite() + || monitor.scale <= 0.0 + { + return None; + } + let physical = if monitor.transform % 2 == 0 { + (monitor.width, monitor.height) + } else { + (monitor.height, monitor.width) + }; + let logical = ( + (f64::from(physical.0) / monitor.scale).round() as u32, + (f64::from(physical.1) / monitor.scale).round() as u32, + ); + (logical.0 > 0 && logical.1 > 0).then_some((physical, logical)) +} + +fn logical_output_size_from_monitors( + monitors: &[Monitor], + width: u32, + height: u32, +) -> Option<(u32, u32)> { + let matches = monitors + .iter() + .filter_map(monitor_physical_and_logical_size) + .filter_map(|(physical, logical)| (physical == (width, height)).then_some(logical)) + .collect::>(); + (matches.len() == 1).then_some(matches[0]) +} + fn layout_from_monitors(monitors: &[Monitor]) -> Option { let rectangles = monitors.iter().filter_map(|monitor| { if monitor.width == 0 @@ -165,13 +209,9 @@ fn layout_from_monitors(monitors: &[Monitor]) -> Option { { return None; } - let (physical_width, physical_height) = if monitor.transform % 2 == 0 { - (monitor.width, monitor.height) - } else { - (monitor.height, monitor.width) - }; - let width = (f64::from(physical_width) / monitor.scale).round() as i64; - let height = (f64::from(physical_height) / monitor.scale).round() as i64; + let (_, (logical_width, logical_height)) = monitor_physical_and_logical_size(monitor)?; + let width = i64::from(logical_width); + let height = i64::from(logical_height); (width > 0 && height > 0).then_some(( i64::from(monitor.x), i64::from(monitor.y), @@ -517,4 +557,31 @@ mod tests { }) ); } + + #[test] + fn physical_capture_size_maps_to_one_logical_output() { + let monitors = [ + monitor(384, 288, 1920, 1080, 1.25), + monitor(1920, 0, 3840, 2160, 1.5), + ]; + assert_eq!( + logical_output_size_from_monitors(&monitors, 1920, 1080), + Some((1536, 864)) + ); + assert_eq!( + logical_output_size_from_monitors(&monitors, 3840, 2160), + Some((2560, 1440)) + ); + assert_eq!( + logical_output_size_from_monitors( + &[ + monitor(0, 0, 1920, 1080, 1.0), + monitor(1920, 0, 1920, 1080, 1.0) + ], + 1920, + 1080, + ), + None + ); + } } From d06a34d3a56aeb6843833e0b7213a2c2318e42de Mon Sep 17 00:00:00 2001 From: Rodri Mora Date: Tue, 11 Aug 2026 01:18:49 +0200 Subject: [PATCH 103/117] fix(cua-driver): preserve Hyprland worker and evidence scope Propagate native Wayland and Hyprland session identity into private workers, preserve explicit 64-bit target ids during recording, classify geometry-free semantic clicks without invented pixel markers, and make two wheel ticks clear a full WebKit viewport. --- .../crates/cua-driver-core/src/recording.rs | 47 +++++++++++++++++-- .../crates/cua-driver-sdk/src/embedded.rs | 15 ++++++ .../rust/crates/cua-driver-testkit/src/e2e.rs | 46 ++++++++++++++++++ .../platform-linux/src/recording_hooks.rs | 18 +++++-- .../crates/platform-linux/src/wayland/mod.rs | 6 +-- 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/recording.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/recording.rs index 362f76118a..08d80b4f95 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/recording.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/recording.rs @@ -532,7 +532,10 @@ impl RecordingSession { if let Ok((resolved_window, resolved_index)) = crate::element_token::global().resolve(pid, token) { - window_id = Some(u64::from(resolved_window)); + // Preserve an explicit compositor-stable 64-bit window id. + // Element tokens retain the cross-platform 32-bit identity and + // cannot reconstruct a Hyprland client address after truncation. + window_id = window_id.or(Some(u64::from(resolved_window))); element_index = u64::try_from(resolved_index).ok(); } } @@ -795,8 +798,15 @@ fn write_turn( // resolved point in action.json is useful diagnostic context, but a // crosshair would falsely imply that a click was delivered. let refused_before_dispatch = click_family && result_is_error && action_refused; - let click_expected = - click_family && !refused_before_target_resolution && !refused_before_dispatch; + // Semantic element actions can dispatch successfully without usable pixel + // geometry (for example, an off-screen GTK control). Do not invent a click + // point or classify the impossible marker as a capture failure; the + // before/after screenshots and fixture-state oracle remain authoritative. + let target_geometry_unavailable = click_family && !result_is_error && click_point.is_none(); + let click_expected = click_family + && !refused_before_target_resolution + && !refused_before_dispatch + && !target_geometry_unavailable; let mut payload = serde_json::json!({ "tool": tool_name, @@ -851,6 +861,8 @@ fn write_turn( "action_refused_before_target_resolution" } else if refused_before_dispatch { "action_refused_before_dispatch" + } else if target_geometry_unavailable { + "target_geometry_unavailable" } else { "not_a_click_action" }, @@ -975,7 +987,7 @@ mod tests { }); set_click_marker_fn(|_, _, _| Some(b"click".to_vec())); set_element_bounds_fn(|window_id, pid, element_index| { - Some((window_id as f64 + element_index as f64, pid as f64)) + (element_index != 999).then_some((window_id as f64 + element_index as f64, pid as f64)) }); let output_dir = std::env::temp_dir().join(format!( @@ -1119,6 +1131,31 @@ mod tests { "action_refused_before_dispatch" ); + let pending = session + .begin_turn( + "click", + &serde_json::json!({ + "pid": 1, + "window_id": 2, + "element_index": 999 + }), + now_ms(), + ) + .expect("geometry-free semantic click should reserve an evidence turn"); + session.finish_turn(pending, "semantic click"); + let geometry_free_turn = output_dir.join("turn-00005"); + let geometry_free_manifest: Value = serde_json::from_slice( + &std::fs::read(geometry_free_turn.join("evidence.json")) + .expect("read geometry-free click evidence"), + ) + .expect("parse geometry-free click evidence"); + assert_eq!(geometry_free_manifest["click"]["status"], "not_applicable"); + assert_eq!( + geometry_free_manifest["click"]["classification"], + "target_geometry_unavailable" + ); + assert!(!geometry_free_turn.join("click.png").exists()); + let files = [ "action.json", "app_state.json", @@ -1136,7 +1173,7 @@ mod tests { } std::fs::remove_dir(directory).expect("remove turn fixture directory"); } - for directory in [&refused_turn, &resolved_refusal_turn] { + for directory in [&refused_turn, &resolved_refusal_turn, &geometry_free_turn] { for file in files.iter().copied().filter(|file| *file != "click.png") { std::fs::remove_file(directory.join(file)) .expect("remove refused turn fixture file"); diff --git a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs index 3445e64f34..93c0e9f333 100644 --- a/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs +++ b/libs/cua-driver/rust/crates/cua-driver-sdk/src/embedded.rs @@ -857,6 +857,9 @@ pub(crate) fn allowed_environment_name(name: &str) -> bool { | "WAYLAND_DISPLAY" | "XDG_RUNTIME_DIR" | "XDG_SESSION_TYPE" + | "XDG_CURRENT_DESKTOP" + | "HYPRLAND_INSTANCE_SIGNATURE" + | "CUA_DRIVER_RS_ENABLE_WAYLAND" | "DBUS_SESSION_BUS_ADDRESS" | "XAUTHORITY" | "CUA_LOG" @@ -1218,6 +1221,9 @@ mod tests { fn environment_is_allowlisted_and_driver_controls_are_reserved() { assert!(allowed_environment_name("PATH")); assert!(allowed_environment_name("WAYLAND_DISPLAY")); + assert!(allowed_environment_name("XDG_CURRENT_DESKTOP")); + assert!(allowed_environment_name("HYPRLAND_INSTANCE_SIGNATURE")); + assert!(allowed_environment_name("CUA_DRIVER_RS_ENABLE_WAYLAND")); assert!(!allowed_environment_name("CUA_DRIVER_PERMISSION_MODE")); assert!(!allowed_environment_name("LD_PRELOAD")); assert!(!allowed_environment_name("NODE_OPTIONS")); @@ -1304,6 +1310,12 @@ mod tests { ("WAYLAND_DISPLAY".into(), "wayland-7".into()), ("XDG_RUNTIME_DIR".into(), "/run/user/1000".into()), ("XDG_SESSION_TYPE".into(), "wayland".into()), + ("XDG_CURRENT_DESKTOP".into(), "Hyprland".into()), + ( + "HYPRLAND_INSTANCE_SIGNATURE".into(), + "fixture-signature".into(), + ), + ("CUA_DRIVER_RS_ENABLE_WAYLAND".into(), "1".into()), ( "DBUS_SESSION_BUS_ADDRESS".into(), "unix:path=/run/user/1000/bus".into(), @@ -1317,6 +1329,9 @@ mod tests { ("WAYLAND_DISPLAY", "wayland-7"), ("XDG_RUNTIME_DIR", "/run/user/1000"), ("XDG_SESSION_TYPE", "wayland"), + ("XDG_CURRENT_DESKTOP", "Hyprland"), + ("HYPRLAND_INSTANCE_SIGNATURE", "fixture-signature"), + ("CUA_DRIVER_RS_ENABLE_WAYLAND", "1"), ("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"), ] { assert!(values diff --git a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs index 453ba1b1ab..295233a268 100644 --- a/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs +++ b/libs/cua-driver/rust/crates/cua-driver-testkit/src/e2e.rs @@ -1629,6 +1629,24 @@ fn validate_one_turn(turn: &Path, cell_id: &str, errors: &mut Vec) { } return; } + let semantic_target_without_geometry = action.as_ref().is_some_and(|value| { + value["result_error"].as_bool() == Some(false) + && value.get("click_point").is_none() + && (value["arguments"].get("element_index").is_some() + || value["arguments"].get("element_token").is_some()) + }); + if semantic_target_without_geometry { + let click = manifest.as_ref().map(|value| &value["click"]); + if !click.is_some_and(|value| { + value["status"].as_str() == Some("not_applicable") + && value["classification"].as_str() == Some("target_geometry_unavailable") + }) { + errors.push(format!( + "invalid geometry-free click evidence for {cell_id}/{turn_name}: expected not_applicable/target_geometry_unavailable" + )); + } + return; + } validate_capture_status( manifest.as_ref(), &["click"], @@ -2159,6 +2177,34 @@ mod tests { .expect("a pre-target refusal must not invent click evidence"); } + #[test] + fn validator_accepts_semantic_click_without_pixel_geometry() { + let (root, case, result, turn) = complete_turn_fixture(); + std::fs::write( + turn.join("action.json"), + br#"{ + "tool":"click", + "arguments":{"pid":1,"window_id":2,"element_index":16}, + "result_error":false + }"#, + ) + .expect("write geometry-free semantic action"); + std::fs::write( + turn.join("evidence.json"), + br#"{ + "schema":"cua-turn-evidence/v1", + "before":{"state":{"status":"captured"},"screenshot":{"status":"captured"}}, + "after":{"state":{"status":"captured"},"screenshot":{"status":"captured"}}, + "click":{"status":"not_applicable","classification":"target_geometry_unavailable"} + }"#, + ) + .expect("write geometry-free semantic evidence"); + std::fs::remove_file(turn.join("click.png")).expect("remove impossible click marker"); + + validate_catalog(&[case], &[result], Some(root.path()), true) + .expect("semantic element delivery need not invent pixel geometry"); + } + #[test] fn validator_rejects_successful_click_marked_not_applicable() { let (root, case, result, turn) = complete_turn_fixture(); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs b/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs index 07af5967bf..d3c98fdc03 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/recording_hooks.rs @@ -107,10 +107,20 @@ fn resolve_window_for_recording( window_id: Option, ) -> Option { let windows = crate::wayland::list_windows_dispatch(Some(pid)); - if crate::wayland::is_wayland() { - // Foreign-toplevel protocol object ids are scoped to one Wayland - // connection. Recording hooks open a fresh connection, so re-resolve - // the target by pid instead of comparing an id from the action call. + if crate::wayland::hyprland::is_session() { + // Hyprland enumeration publishes the compositor's stable 64-bit client + // address, not a connection-local foreign-toplevel object id. Preserve + // it so evidence for same-process child windows and popovers captures + // the action's exact surface instead of whichever sibling was listed + // first. + match window_id { + Some(window_id) => windows.into_iter().find(|window| window.xid == window_id), + None => windows.into_iter().next(), + } + } else if crate::wayland::is_wayland() { + // Generic foreign-toplevel protocol object ids are scoped to one + // Wayland connection. Recording hooks open a fresh connection, so + // re-resolve the target by pid instead of comparing an action-call id. windows.into_iter().next() } else if let Some(window_id) = window_id { windows.into_iter().find(|window| window.xid == window_id) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 17b6828422..58dee4a566 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -1835,10 +1835,10 @@ fn scroll_vptr( other => anyhow::bail!("unknown scroll direction: {other}"), }; // axis_discrete: `value` is logical units (the wayland-rs wrapper - // converts to wl_fixed internally); `discrete` is the tick count. Fifteen + // converts to wl_fixed internally); `discrete` is the tick count. Sixteen // units matches a conventional wheel notch and advances Chromium/WebKit by - // more than one 128 px fixture viewport across two requested ticks. - let value: f64 = (sign as f64) * 15.0; + // at least one 128 px fixture viewport across two requested ticks. + let value: f64 = (sign as f64) * 16.0; for i in 0..amount.max(1) { if i > 0 { std::thread::sleep(std::time::Duration::from_millis(25)); From 79f42a349225a49c049fb2a9e2aac12a502d3f44 Mon Sep 17 00:00:00 2001 From: injaneity <44902825+injaneity@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:08:26 +0000 Subject: [PATCH 104/117] fix(cua-driver): render browser cursor on Linux --- .../cua-driver-core/src/browser/platform.rs | 8 +- .../cua-driver-core/src/browser/tools.rs | 6 +- .../cua-driver-core/src/browser/v2_tests.rs | 7 +- .../platform-linux/src/browser_platform.rs | 211 +++++++++++++++++- .../crates/platform-linux/src/tools/impl_.rs | 6 +- 5 files changed, 226 insertions(+), 12 deletions(-) diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs index 387958eb8c..cbe67fadd5 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/platform.rs @@ -287,10 +287,10 @@ pub trait BrowserPlatform: Send + Sync { } /// Best-effort, visual-only feedback for an authorized browser action. - /// The default is a no-op so platforms without an agent-cursor overlay do - /// not change behavior. Implementations must not deliver input or alter - /// focus/z-order; failures are intentionally not part of browser results. - async fn visualize_browser_action(&self, _action: BrowserVisualAction) {} + /// Implementations without an agent-cursor overlay must explicitly opt out + /// with a no-op. Implementations must not deliver input or alter focus or + /// z-order; failures are intentionally not part of browser results. + async fn visualize_browser_action(&self, action: BrowserVisualAction); /// Classify `pid`: is it a browser, which engine family, can it do /// CDP at all. Must not have side effects. diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs index f16fb1793c..9b80ecb280 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/tools.rs @@ -2482,7 +2482,9 @@ impl Tool for BrowserSetInputFilesTool { #[cfg(test)] mod tests { use super::*; - use crate::browser::platform::{BrowserPlatform, PrepareOutcome, PrepareRequest}; + use crate::browser::platform::{ + BrowserPlatform, BrowserVisualAction, PrepareOutcome, PrepareRequest, + }; use crate::browser::types::{ BrowserClassification, BrowserEngineFamily, BrowserProduct, NativeWindowInfo, OwnedEndpoint, ProcessFingerprint, @@ -2495,6 +2497,8 @@ mod tests { #[async_trait] impl BrowserPlatform for MockPlatform { + async fn visualize_browser_action(&self, _action: BrowserVisualAction) {} + async fn classify_browser( &self, pid: i64, diff --git a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs index 2b82930cdc..76dbc35da7 100644 --- a/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs +++ b/libs/cua-driver/rust/crates/cua-driver-core/src/browser/v2_tests.rs @@ -22,8 +22,9 @@ use crate::tool::Tool; use super::engine::BrowserEngine; use super::mock_cdp::{MockCdpServer, MockEvent, MockHandler, MockReply}; use super::platform::{ - BrowserConsentOutcome, BrowserConsentRequest, BrowserPlatform, ExistingProfileSetupOutcome, - ExistingProfileSetupRequest, PrepareAction, PrepareOutcome, PrepareRequest, + BrowserConsentOutcome, BrowserConsentRequest, BrowserPlatform, BrowserVisualAction, + ExistingProfileSetupOutcome, ExistingProfileSetupRequest, PrepareAction, PrepareOutcome, + PrepareRequest, }; use super::pointer::BrowserPointerTool; use super::refusal::BrowserRefusal; @@ -678,6 +679,8 @@ impl BrowserPlatform for FixturePlatform { .then_some("fixture trusted input raises the standalone window") } + async fn visualize_browser_action(&self, _action: BrowserVisualAction) {} + async fn classify_browser(&self, _pid: i64) -> Result { Ok(BrowserClassification { is_browser: true, diff --git a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs index 0816d173d8..9be1cfa637 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/browser_platform.rs @@ -3,14 +3,15 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::os::unix::fs::MetadataExt; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; use cua_driver_core::browser::existing_profile_setup_descriptor; use cua_driver_core::browser::platform::{ select_isolated_browser_executable, BrowserConsentOutcome, BrowserConsentRequest, - BrowserPlatform, ExistingProfileSetupOutcome, ExistingProfileSetupRequest, PrepareAction, - PrepareOutcome, PrepareRequest, + BrowserPlatform, BrowserVisualAction, BrowserVisualActionKind, ExistingProfileSetupOutcome, + ExistingProfileSetupRequest, PrepareAction, PrepareOutcome, PrepareRequest, }; use cua_driver_core::browser::refusal::{BrowserRefusal, BrowserRefusalCode}; use cua_driver_core::browser::types::{ @@ -20,8 +21,69 @@ use cua_driver_core::browser::types::{ }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +pub struct LinuxBrowserPlatform { + cursor_registry: Arc, + browser_cursors: Mutex, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BrowserCursorBinding { + window_id: u64, + cdp_target_id: String, +} + #[derive(Debug, Default)] -pub struct LinuxBrowserPlatform; +struct BrowserCursorTracker { + bindings: HashMap, +} + +impl BrowserCursorTracker { + fn update( + &mut self, + session: &str, + window_id: u64, + cdp_target_id: &str, + tab_is_active: bool, + ) -> Vec<(String, bool)> { + self.bindings.insert( + session.to_owned(), + BrowserCursorBinding { + window_id, + cdp_target_id: cdp_target_id.to_owned(), + }, + ); + + if !tab_is_active { + return vec![(session.to_owned(), false)]; + } + + self.bindings + .iter() + .filter(|(_, binding)| binding.window_id == window_id) + .map(|(key, binding)| { + ( + key.clone(), + key == session && binding.cdp_target_id == cdp_target_id, + ) + }) + .collect() + } +} + +impl LinuxBrowserPlatform { + pub fn new(cursor_registry: Arc) -> Self { + Self { + cursor_registry, + browser_cursors: Mutex::new(BrowserCursorTracker::default()), + } + } +} + +impl Default for LinuxBrowserPlatform { + fn default() -> Self { + Self::new(Arc::new(cursor_overlay::CursorRegistry::new())) + } +} fn refusal(code: BrowserRefusalCode, message: impl Into) -> BrowserRefusal { BrowserRefusal::new(code, message) @@ -472,6 +534,76 @@ impl BrowserPlatform for LinuxBrowserPlatform { Some("Chromium's trusted CDP Input route activates its standalone browser window on Linux") } + async fn visualize_browser_action(&self, action: BrowserVisualAction) { + if action.session.is_empty() + || action.cdp_target_id.is_empty() + || cua_driver_core::session::is_session_ended(&action.session) + { + return; + } + + let visibility_updates = self.browser_cursors.lock().unwrap().update( + &action.session, + action.window_id, + &action.cdp_target_id, + action.tab_is_active, + ); + let cursor_enabled = self + .cursor_registry + .get_or_create(&action.session) + .config + .enabled; + for (key, visible) in visibility_updates { + let enabled = if key == action.session { + visible && cursor_enabled + } else { + visible + && self + .cursor_registry + .get(&key) + .is_some_and(|state| state.config.enabled) + }; + crate::overlay::send_command_for( + key, + cursor_overlay::OverlayCommand::SetEnabled(enabled), + ); + } + if !action.tab_is_active || !cursor_enabled { + return; + } + let (Some(screen_x), Some(screen_y)) = (action.screen_x, action.screen_y) else { + return; + }; + if !screen_x.is_finite() || !screen_y.is_finite() { + return; + } + + crate::overlay::send_command_for( + action.session.clone(), + cursor_overlay::OverlayCommand::PinAbove(action.window_id), + ); + crate::overlay::animate_cursor_to_for(action.session.clone(), screen_x, screen_y).await; + self.cursor_registry + .update_position(&action.session, screen_x, screen_y); + + if matches!( + action.kind, + BrowserVisualActionKind::Click + | BrowserVisualActionKind::Type + | BrowserVisualActionKind::RightClick + | BrowserVisualActionKind::DoubleClick + | BrowserVisualActionKind::Drag + ) { + crate::overlay::send_command_for( + action.session, + cursor_overlay::OverlayCommand::ClickPulse { + x: screen_x, + y: screen_y, + }, + ); + } + } + async fn classify_browser(&self, pid: i64) -> Result { let pid_u32 = u32::try_from(pid).map_err(|_| { refusal( @@ -1153,6 +1285,79 @@ impl BrowserPlatform for LinuxBrowserPlatform { mod tests { use super::*; + #[tokio::test] + async fn browser_visual_feedback_updates_the_declared_session_cursor() { + let registry = Arc::new(cursor_overlay::CursorRegistry::new()); + let platform = LinuxBrowserPlatform::new(registry.clone()); + platform + .visualize_browser_action(BrowserVisualAction { + session: "browser-cursor-test".to_owned(), + window_id: 77, + cdp_target_id: "tab-A".to_owned(), + tab_is_active: true, + screen_x: Some(321.0), + screen_y: Some(456.0), + kind: BrowserVisualActionKind::Click, + }) + .await; + + let state = registry + .get("browser-cursor-test") + .expect("browser action should materialize its session cursor"); + assert_eq!((state.x, state.y), (Some(321.0), Some(456.0))); + } + + #[tokio::test] + async fn inactive_tab_feedback_materializes_but_does_not_move_its_cursor() { + let registry = Arc::new(cursor_overlay::CursorRegistry::new()); + let platform = LinuxBrowserPlatform::new(registry.clone()); + platform + .visualize_browser_action(BrowserVisualAction { + session: "browser-cursor-hidden".to_owned(), + window_id: 77, + cdp_target_id: "tab-hidden".to_owned(), + tab_is_active: false, + screen_x: Some(321.0), + screen_y: Some(456.0), + kind: BrowserVisualActionKind::Click, + }) + .await; + + let state = registry + .get("browser-cursor-hidden") + .expect("browser action should establish its session-to-tab binding"); + assert!( + state.x.is_none() && state.y.is_none(), + "an inactive tab must not animate or move its visible cursor" + ); + } + + #[test] + fn browser_cursor_tracker_shows_only_the_active_tabs_session_per_window() { + let mut tracker = BrowserCursorTracker::default(); + assert_eq!( + tracker.update("session-red", 77, "tab-A", false), + vec![("session-red".to_owned(), false)] + ); + + let first_active = tracker.update("session-red", 77, "tab-A", true); + assert_eq!(first_active, vec![("session-red".to_owned(), true)]); + + let second_active = tracker + .update("session-blue", 77, "tab-B", true) + .into_iter() + .collect::>(); + assert_eq!(second_active.get("session-red"), Some(&false)); + assert_eq!(second_active.get("session-blue"), Some(&true)); + + let other_window = tracker.update("session-green", 88, "tab-C", true); + assert_eq!( + other_window, + vec![("session-green".to_owned(), true)], + "an active tab in another native window must not hide this window" + ); + } + #[test] fn isolated_browser_candidates_use_only_root_managed_payloads() { let candidates = isolated_browser_candidates(); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e71a9c8f34..51b442a728 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -7372,7 +7372,7 @@ impl Tool for KillAppTool { .and_then(Value::as_i64) .filter(|pid| *pid > 0) .ok_or_else(|| "kill_app requires a positive integer pid".to_owned())?; - let fingerprint = crate::browser_platform::LinuxBrowserPlatform + let fingerprint = crate::browser_platform::LinuxBrowserPlatform::default() .process_fingerprint(pid) .await .map_err(|error| error.message)?; @@ -8036,7 +8036,9 @@ pub fn build_registry_with_provider( super::page::LinuxPageBackend::new(), )))); let browser_engine = cua_driver_core::browser::BrowserEngine::new_with_runtime_services( - Arc::new(crate::browser_platform::LinuxBrowserPlatform), + Arc::new(crate::browser_platform::LinuxBrowserPlatform::new( + state.cursor_registry.clone(), + )), r.approval_broker(), r.protected_resource_ownership(), ); From d60f71fd733b7fc29cd006540a0d1b6376c291d8 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 13:16:50 -0500 Subject: [PATCH 105/117] fix(cua-driver): isolate Wayland cursors across outputs Create one initialized layer-shell surface per enabled output, preserve independent named-session cursor state, and keep explicit lifecycle revival aligned with the current session contract. Adapted with permission from the focused Wayland overlay commits in spencerbull/cua. Salvaged from #3151 Co-authored-by: Spencer Bull <7035687+spencerbull@users.noreply.github.com> --- .../rust/crates/platform-linux/Cargo.toml | 2 +- .../rust/crates/platform-linux/src/lib.rs | 2 +- .../platform-linux/src/wayland/overlay.rs | 1403 +++++++++++++---- 3 files changed, 1138 insertions(+), 269 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml index 5a54b732aa..b6f74fa209 100644 --- a/libs/cua-driver/rust/crates/platform-linux/Cargo.toml +++ b/libs/cua-driver/rust/crates/platform-linux/Cargo.toml @@ -53,7 +53,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] } # wp-viewporter (HiDPI for the layer-shell overlay). The transitive pull # from wayland-protocols-wlr does NOT enable `staging`; declaring it here # unifies the feature globally. -wayland-protocols = { version = "0.32", features = ["client", "staging"] } +wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } # xdg-desktop-portal client: display-screenshot tier # (org.freedesktop.portal.Screenshot), per-window ScreenCast # (org.freedesktop.portal.ScreenCast), and the libei input session diff --git a/libs/cua-driver/rust/crates/platform-linux/src/lib.rs b/libs/cua-driver/rust/crates/platform-linux/src/lib.rs index 6298435c3e..f7d13facb7 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/lib.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/lib.rs @@ -149,7 +149,7 @@ pub fn register_tools_with_cursor_and_provider( #[cfg(target_os = "linux")] wayland::ensure_nested_session(); #[cfg(target_os = "linux")] - wayland::overlay::set_config_enabled(cfg.enabled); + wayland::overlay::set_config(cfg.clone()); if cfg.enabled { overlay::init(cfg.clone()); overlay::run_on_thread(); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs index 088846188e..2d05df3a30 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs @@ -2,7 +2,7 @@ //! //! Replaces the X11-only `overlay.rs` render loop on wlroots compositors //! (sway, labwc, kwin 5.27+, hyprland) by creating a full-screen, -//! click-through, always-on-top `wl_surface` anchored to the first output +//! click-through, always-on-top `wl_surface` on every enabled output //! via `zwlr_layer_shell_v1`. The surface renders the same gradient-arrow //! cursor as the X11 path by sharing `cursor_overlay::RenderStateCore` — //! bloom, click-pulse, idle-fade, and motion all work identically. @@ -15,9 +15,9 @@ //! owner thread (`cua-overlay-wl`) holds the wayland Connection + //! EventQueue + layer surface; commands flow in over a `crossbeam-channel`. //! The render core wakes at frame cadence only while pixels can change; -//! stable or hidden state blocks on the command channel without polling. +//! stable or hidden state performs only a cheap, one-second topology check. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{ atomic::{AtomicBool, Ordering}, OnceLock, @@ -26,7 +26,7 @@ use std::thread; use std::time::{Duration, Instant}; use crossbeam_channel::{bounded, Receiver, Sender}; -use cursor_overlay::{CursorConfig, OverlayCommand, OverlayMsg, RenderStateCore}; +use cursor_overlay::{CursorConfig, CursorKey, OverlayCommand, OverlayMsg, RenderStateCore}; use wayland_client::{ protocol::{ wl_buffer::WlBuffer, @@ -40,6 +40,10 @@ use wayland_client::{ }, Connection, Dispatch, Proxy, QueueHandle, }; +use wayland_protocols::xdg::xdg_output::zv1::client::{ + zxdg_output_manager_v1::ZxdgOutputManagerV1, + zxdg_output_v1::{self, ZxdgOutputV1}, +}; use wayland_protocols_wlr::layer_shell::v1::client::{ zwlr_layer_shell_v1::{Layer, ZwlrLayerShellV1}, zwlr_layer_surface_v1::{self, Anchor, KeyboardInteractivity, ZwlrLayerSurfaceV1}, @@ -50,8 +54,9 @@ use wayland_protocols_wlr::layer_shell::v1::client::{ /// SetPressed) are forwarded as-is so the layer-shell overlay matches the /// X11 visual: bloom + animated arrow + click pulse + press ring. enum WlOverlayCmd { - Cmd { cmd: OverlayCommand }, - Remove, + Cmd { key: CursorKey, cmd: OverlayCommand }, + Remove(CursorKey), + Revive(CursorKey), Shutdown, } @@ -60,9 +65,11 @@ static TX: OnceLock> = OnceLock::new(); // opt the native Wayland overlay in with the daemon's CursorConfig. This keeps // lazy forwarding from bypassing --no-overlay before any window exists. static CONFIG_ENABLED: AtomicBool = AtomicBool::new(false); +static CONFIG_TEMPLATE: OnceLock = OnceLock::new(); -pub fn set_config_enabled(enabled: bool) { - CONFIG_ENABLED.store(enabled, Ordering::Release); +pub fn set_config(config: CursorConfig) { + CONFIG_ENABLED.store(config.enabled, Ordering::Release); + let _ = CONFIG_TEMPLATE.set(config); } fn tx() -> Option<&'static Sender> { @@ -98,9 +105,9 @@ pub fn forward(msg: &OverlayMsg) -> bool { if !should_forward(CONFIG_ENABLED.load(Ordering::Acquire), msg) { return false; } - // The native Wayland path owns one cursor and does not keep keyed session - // tombstones. Accept revival without starting the compositor thread. - if matches!(msg, OverlayMsg::Revive(_)) { + // If the owner has never started, it cannot hold a tombstone. Accept the + // lifecycle transition without paying the compositor startup cost. + if matches!(msg, OverlayMsg::Revive(_)) && tx().is_none() { return true; } // Lazy startup: spawning the layer-shell owner thread + connecting to @@ -113,22 +120,18 @@ pub fn forward(msg: &OverlayMsg) -> bool { return false; } let Some(tx) = tx() else { return false }; + map_overlay_msg(msg).is_some_and(|cmd| tx.try_send(cmd).is_ok()) +} + +fn map_overlay_msg(msg: &OverlayMsg) -> Option { match msg { - OverlayMsg::Remove(k) => { - let _ = k; - let _ = tx.try_send(WlOverlayCmd::Remove); - true - } - OverlayMsg::Cmd(kc) => { - if matches!(&kc.cmd, OverlayCommand::ShowFocusRect(_)) { - return false; - } - let _ = tx.try_send(WlOverlayCmd::Cmd { - cmd: kc.cmd.clone(), - }); - true - } - OverlayMsg::Revive(_) => true, + OverlayMsg::Remove(key) => Some(WlOverlayCmd::Remove(key.clone())), + OverlayMsg::Cmd(kc) if matches!(&kc.cmd, OverlayCommand::ShowFocusRect(_)) => None, + OverlayMsg::Cmd(kc) => Some(WlOverlayCmd::Cmd { + key: kc.key.clone(), + cmd: kc.cmd.clone(), + }), + OverlayMsg::Revive(key) => Some(WlOverlayCmd::Revive(key.clone())), } } @@ -154,15 +157,25 @@ struct OverlayState { compositor: Option, shm: Option, layer_shell: Option, - output: Option, - output_w: u32, - output_h: u32, - surface: Option, - layer_surface: Option, - configured: bool, - /// Cross-platform render core: position, animation, gradient arrow, - /// bloom, click pulse, idle-fade. Shared verbatim with the X11 path. - core: RenderStateCore, + xdg_output_manager: Option, + outputs: HashMap, + /// Outputs whose most recently committed buffer contains cursor pixels. + /// Usually this contains exactly one output; keeping a set makes hide, + /// removal, and topology changes clear every previously painted surface. + painted_outputs: HashSet, + /// Configured layer surfaces that have received at least one wl_buffer. + /// This is intentionally separate from `painted_outputs`: every new or + /// reconfigured surface needs a one-shot transparent commit to complete + /// its layer-shell handshake, but stable empty surfaces must not trigger + /// recurring full-output SHM redraws. + initialized_outputs: HashSet, + topology_dirty: bool, + /// Keyed render cores mirror the X11 native overlay contract. Removing a + /// named key records it in `ended`, so already-queued late commands cannot + /// recreate a cursor after end_session. + cores: HashMap, + template: CursorConfig, + ended: HashSet, /// In-flight wl_shm buffers awaiting `wl_buffer.release` from the /// compositor. Keyed by `WlBuffer` object id; value is the /// `(mmap ptr, mmap size, memfd fd)` triple that must be unmapped + @@ -172,6 +185,107 @@ struct OverlayState { pending_buffers: HashMap, } +struct NativeOutput { + output: WlOutput, + xdg_output: Option, + surface: Option, + layer_surface: Option, + wl_origin: Option<(i32, i32)>, + logical_origin: Option<(i32, i32)>, + logical_size: Option<(u32, u32)>, + configured_size: Option<(u32, u32)>, + mode_size: Option<(u32, u32)>, + scale: i32, + name: Option, + closed: bool, +} + +impl NativeOutput { + fn new(output: WlOutput) -> Self { + Self { + output, + xdg_output: None, + surface: None, + layer_surface: None, + wl_origin: None, + logical_origin: None, + logical_size: None, + configured_size: None, + mode_size: None, + scale: 1, + name: None, + closed: false, + } + } + + fn layout(&self, id: u32) -> Option { + if self.layer_surface.is_none() || self.configured_size.is_none() { + return None; + } + let (origin_x, origin_y) = self.logical_origin.or(self.wl_origin).unwrap_or((0, 0)); + let (width, height) = self.logical_size.or(self.configured_size).or_else(|| { + let scale = self.scale.max(1) as u32; + self.mode_size + .map(|(width, height)| ((width / scale).max(1), (height / scale).max(1))) + })?; + (width > 0 && height > 0).then_some(OutputLayout { + id, + origin_x, + origin_y, + width, + height, + }) + } + + fn destroy_surfaces(&mut self) { + self.close_layer(); + if let Some(xdg_output) = self.xdg_output.take() { + xdg_output.destroy(); + } + } + + fn close_layer(&mut self) { + if let Some(layer_surface) = self.layer_surface.take() { + layer_surface.destroy(); + } + if let Some(surface) = self.surface.take() { + surface.destroy(); + } + self.configured_size = None; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct OutputLayout { + id: u32, + origin_x: i32, + origin_y: i32, + width: u32, + height: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct SelectedOutput { + id: u32, + local_x: f64, + local_y: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FrameTarget { + id: u32, +} + +#[derive(Clone, Copy)] +struct OutputData { + id: u32, +} + +#[derive(Clone, Copy)] +struct LayerData { + id: u32, +} + // SAFETY: the raw pointers in pending_buffers point at mmap regions owned // exclusively by this thread (the owner thread). OverlayState is never // shared across threads — wayland-client's EventQueue is !Send so @@ -180,119 +294,305 @@ struct OverlayState { // explicit assertion. unsafe impl Send for OverlayState {} -impl Default for OverlayState { - fn default() -> Self { +impl Drop for OverlayState { + fn drop(&mut self) { + for (_, (ptr, size, fd)) in std::mem::take(&mut self.pending_buffers) { + super::cleanup_mmap(ptr, size, fd); + } + } +} + +impl OverlayState { + fn new(template: CursorConfig) -> Self { + let mut cores = HashMap::new(); + // Match the X11 contract: the compatibility/default slot preserves the + // launch-time cursor id, while lazily-created named slots override it. + cores.insert("default".to_owned(), RenderStateCore::new(template.clone())); Self { compositor: None, shm: None, layer_shell: None, - output: None, - output_w: 0, - output_h: 0, - surface: None, - layer_surface: None, - configured: false, - core: RenderStateCore::new(CursorConfig::default()), + xdg_output_manager: None, + outputs: HashMap::new(), + painted_outputs: HashSet::new(), + initialized_outputs: HashSet::new(), + topology_dirty: false, + cores, + template, + ended: HashSet::new(), pending_buffers: HashMap::new(), } } } +fn render_core_for_key(template: &CursorConfig, key: &str) -> RenderStateCore { + let mut config = template.clone(); + config.cursor_id = key.to_owned(); + RenderStateCore::new(config) +} + +fn apply_keyed_command( + cores: &mut HashMap, + template: &CursorConfig, + ended: &HashSet, + key: CursorKey, + cmd: OverlayCommand, +) -> bool { + if ended.contains(&key) { + tracing::debug!(key = %key, cmd = ?cmd, "wayland overlay: command dropped — key was ended"); + return false; + } + + let core = cores + .entry(key.clone()) + .or_insert_with(|| render_core_for_key(template, &key)); + // Seed from the off-screen sentinel near the first targeted action so a + // spring animation begins on-screen. This mirrors the X11 renderer. + let seed_target = match &cmd { + OverlayCommand::MoveTo { x, y, .. } + | OverlayCommand::SnapTo { x, y, .. } + | OverlayCommand::ClickPulse { x, y } => Some((*x, *y)), + _ => None, + }; + if let Some((target_x, target_y)) = seed_target { + if core.pos.0 < -50.0 { + const SEED_OFFSET: f64 = 16.0; + core.pos = ( + (target_x - SEED_OFFSET).max(2.0), + (target_y - SEED_OFFSET).max(2.0), + ); + } + } + + let disabling = matches!(&cmd, OverlayCommand::SetEnabled(false)); + let dirty = core.apply_command_base(cmd, false, false); + if disabling { + quiesce_hidden(core); + } + dirty +} + +fn remove_keyed_core( + cores: &mut HashMap, + ended: &mut HashSet, + key: CursorKey, +) -> bool { + if key == "default" { + return false; + } + let removed = cores.remove(&key).is_some(); + ended.insert(key); + removed +} + +fn revive_key(ended: &mut HashSet, key: CursorKey) { + if key != "default" { + ended.remove(&key); + } +} + fn dbg(msg: &str) { if std::env::var_os("CUA_OVERLAY_DEBUG").is_some() { eprintln!("[cua-overlay-wl] {msg}"); } } -fn owner_thread(rx: Receiver) -> anyhow::Result<()> { - let conn = Connection::connect_to_env()?; - let mut queue = conn.new_event_queue::(); - let qh = queue.handle(); - let _registry = conn.display().get_registry(&qh, ()); +fn select_output(layouts: &[OutputLayout], x: f64, y: f64) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + layouts + .iter() + .filter(|layout| output_contains(layout, x, y)) + .min_by_key(|layout| layout.id) + .map(|layout| SelectedOutput { + id: layout.id, + local_x: x - f64::from(layout.origin_x), + local_y: y - f64::from(layout.origin_y), + }) +} - let mut state = OverlayState::default(); - queue.roundtrip(&mut state)?; - for _ in 0..3 { - queue.roundtrip(&mut state)?; +fn output_contains(layout: &OutputLayout, x: f64, y: f64) -> bool { + x.is_finite() + && y.is_finite() + && x >= f64::from(layout.origin_x) + && y >= f64::from(layout.origin_y) + && x < f64::from(layout.origin_x) + f64::from(layout.width) + && y < f64::from(layout.origin_y) + f64::from(layout.height) +} + +fn frame_plan( + layouts: &[OutputLayout], + painted_outputs: &HashSet, + initialized_outputs: &HashSet, + cursor_positions: impl IntoIterator, +) -> (HashSet, Vec) { + let selected: HashSet = cursor_positions + .into_iter() + .filter_map(|(x, y)| select_output(layouts, x, y).map(|output| output.id)) + .collect(); + let mut ids: Vec = painted_outputs.iter().copied().collect(); + ids.extend( + layouts + .iter() + .map(|layout| layout.id) + .filter(|id| !initialized_outputs.contains(id)), + ); + for id in &selected { + if !ids.contains(id) { + ids.push(*id); + } } + ids.sort_unstable(); + ids.dedup(); + let targets = ids.into_iter().map(|id| FrameTarget { id }).collect(); + (selected, targets) +} - let compositor = state - .compositor - .clone() - .ok_or_else(|| anyhow::anyhow!("compositor does not expose wl_compositor"))?; - let shm = state - .shm - .clone() - .ok_or_else(|| anyhow::anyhow!("compositor does not expose wl_shm"))?; - let layer_shell = state - .layer_shell - .clone() - .ok_or_else(|| anyhow::anyhow!("compositor does not expose zwlr_layer_shell_v1"))?; - let output = state - .output - .clone() - .ok_or_else(|| anyhow::anyhow!("compositor exposed no wl_output"))?; +fn visible_cores_for_output<'a>( + cores: &'a HashMap, + layouts: &[OutputLayout], + output_id: u32, +) -> Vec<(&'a CursorKey, &'a RenderStateCore)> { + let mut visible_cores: Vec<_> = cores + .iter() + .filter(|(_, core)| { + core.visible + && core.pos.0 >= -100.0 + && core.idle_alpha >= 0.004 + && select_output(layouts, core.pos.0, core.pos.1) + .is_some_and(|selected| selected.id == output_id) + }) + .collect(); + visible_cores.sort_by(|(left, _), (right, _)| left.cmp(right)); + visible_cores +} + +fn ensure_output_resources(state: &mut OverlayState, qh: &QueueHandle) { + let ids: Vec = state.outputs.keys().copied().collect(); + for id in ids { + ensure_xdg_output(state, id, qh); + ensure_layer_surface(state, id, qh); + } +} + +fn ensure_xdg_output(state: &mut OverlayState, id: u32, qh: &QueueHandle) { + let Some(manager) = state.xdg_output_manager.clone() else { + return; + }; + let Some(output) = state.outputs.get_mut(&id) else { + return; + }; + if output.xdg_output.is_none() { + output.xdg_output = Some(manager.get_xdg_output(&output.output, qh, OutputData { id })); + } +} + +fn ensure_layer_surface(state: &mut OverlayState, id: u32, qh: &QueueHandle) { + let (Some(compositor), Some(layer_shell)) = + (state.compositor.clone(), state.layer_shell.clone()) + else { + return; + }; + let Some(output) = state.outputs.get_mut(&id) else { + return; + }; + if output.layer_surface.is_some() || output.closed { + return; + } - // Build the layer surface: fullscreen, overlay layer, click-through. - let surface = compositor.create_surface(&qh, ()); + let surface = compositor.create_surface(qh, ()); let layer_surface = layer_shell.get_layer_surface( &surface, - Some(&output), + Some(&output.output), Layer::Overlay, "cua-agent-cursor".to_string(), - &qh, - (), + qh, + LayerData { id }, ); - // Anchor to all four edges = full screen. layer_surface.set_anchor(Anchor::Top | Anchor::Bottom | Anchor::Left | Anchor::Right); layer_surface.set_size(0, 0); layer_surface.set_exclusive_zone(-1); layer_surface.set_keyboard_interactivity(KeyboardInteractivity::None); - // Click-through: empty input region. Standard Wayland intentionally does - // not expose another client's global pointer position, so this surface - // cannot implement the macOS/Windows/X11 badge-hover reveal without a - // compositor-owned adapter. Giving it an input region would steal the - // user's pointer events instead of observing them. - let region: WlRegion = compositor.create_region(&qh, ()); + let region: WlRegion = compositor.create_region(qh, ()); surface.set_input_region(Some(®ion)); region.destroy(); - state.surface = Some(surface); - state.layer_surface = Some(layer_surface); + surface.commit(); + output.surface = Some(surface); + output.layer_surface = Some(layer_surface); +} - // First commit kicks off the configure handshake. - if let Some(s) = state.surface.as_ref() { - s.commit(); +fn owner_thread(rx: Receiver) -> anyhow::Result<()> { + let conn = Connection::connect_to_env()?; + let mut queue = conn.new_event_queue::(); + let qh = queue.handle(); + let _registry = conn.display().get_registry(&qh, ()); + + let template = CONFIG_TEMPLATE.get().cloned().unwrap_or_default(); + let mut state = OverlayState::new(template); + queue.roundtrip(&mut state)?; + + state + .compositor + .clone() + .ok_or_else(|| anyhow::anyhow!("compositor does not expose wl_compositor"))?; + let shm = state + .shm + .clone() + .ok_or_else(|| anyhow::anyhow!("compositor does not expose wl_shm"))?; + state + .layer_shell + .clone() + .ok_or_else(|| anyhow::anyhow!("compositor does not expose zwlr_layer_shell_v1"))?; + if state.outputs.is_empty() { + anyhow::bail!("compositor exposed no wl_output"); } - // Wait for the first configure event so we know the output dimensions - // before drawing. + // Build one fullscreen, click-through layer surface per advertised output. + ensure_output_resources(&mut state, &qh); + + // Give every enabled output a chance to configure. Disabled outputs may + // stay advertised without configuring and are excluded from selection. for _ in 0..10 { queue.roundtrip(&mut state)?; - if state.configured && state.output_w > 0 && state.output_h > 0 { + ensure_output_resources(&mut state, &qh); + if state + .outputs + .values() + .filter(|output| output.layer_surface.is_some()) + .all(|output| output.configured_size.is_some()) + { break; } - std::thread::sleep(std::time::Duration::from_millis(50)); } - if !state.configured { - anyhow::bail!("layer surface never received configure event"); + let configured_outputs = state + .outputs + .values() + .filter(|output| output.configured_size.is_some()) + .count(); + if configured_outputs == 0 { + anyhow::bail!("no layer surface received a configure event"); } - dbg(&format!( - "configured: w={} h={}", - state.output_w, state.output_h - )); + dbg(&format!("configured outputs: {configured_outputs}")); + state.topology_dirty = false; + + // A layer-shell surface is not fully initialized until the first buffer is + // attached after configure. Commit one transparent buffer to every empty + // output now, before entering the demand-driven loop; a cursor already + // selected on an output can share this same first frame. + redraw(&mut state, &shm, &qh)?; + queue.roundtrip(&mut state)?; - // Demand-driven main loop. A stable cursor blocks on the command channel; - // only active motion, click/fade animation, or the exact idle-fade - // deadline schedules a wake. This avoids display-sized SHM allocation and - // conversion at 60Hz when no pixel can change while preserving immediate - // command wakeups. + // Demand-driven main loop. Stable cursors perform only a cheap Wayland + // maintenance roundtrip once per second so output topology changes are + // observed; full-display SHM work remains limited to visual changes. let mut last_tick = Instant::now(); let mut frame_tick_needed = false; loop { - let wait = next_wait(&state.core, frame_tick_needed); + let wait = next_wait(&state.cores, frame_tick_needed, state.topology_dirty); let (first_cmd, timed_out) = match wait_for_work(&rx, wait) { WlWake::Command(cmd) => (Some(cmd), None), WlWake::Timeout => (None, Some(wait)), @@ -313,44 +613,20 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { shutdown = true; break; } - Ok(WlOverlayCmd::Cmd { cmd }) => { - // Seed: if the cursor is still at the off-screen sentinel - // `(-200, -200)` from `RenderStateCore::new`, snap to a - // point near the MoveTo / SnapTo target so the spring - // animation starts on-screen. Mirrors X11 overlay.rs's - // `seed_start_if_sentinel` helper — without it, the - // spring oscillates around the sentinel and the cursor - // never reaches the screen. - let seed_target = match &cmd { - OverlayCommand::MoveTo { x, y, .. } - | OverlayCommand::SnapTo { x, y, .. } - | OverlayCommand::ClickPulse { x, y } => Some((*x, *y)), - _ => None, - }; - if let Some((tx, ty)) = seed_target { - if state.core.pos.0 < -50.0 { - const SEED_OFFSET: f64 = 16.0; - let sx = (tx - SEED_OFFSET).max(2.0); - let sy = (ty - SEED_OFFSET).max(2.0); - state.core.pos = (sx, sy); - } - } - // apply_command_base consumes every variant the X11 - // path handles. `move_to_snap_sentinel` / `click_pulse - // _sentinel_only` are both `false` here — same as X11. - let disabling = matches!(&cmd, OverlayCommand::SetEnabled(false)); - dirty |= state.core.apply_command_base(cmd, false, false); - if disabling { - quiesce_hidden(&mut state.core); - } + Ok(WlOverlayCmd::Cmd { key, cmd }) => { + dirty |= apply_keyed_command( + &mut state.cores, + &state.template, + &state.ended, + key, + cmd, + ); + } + Ok(WlOverlayCmd::Remove(key)) => { + dirty |= remove_keyed_core(&mut state.cores, &mut state.ended, key); } - Ok(WlOverlayCmd::Remove) => { - // Single-cursor overlay: removing the active cursor - // hides it. Multi-cursor wlroots support can layer on - // top of this in a follow-up if needed. - dirty |= state.core.visible || state.core.pos.0 >= -100.0; - state.core.visible = false; - quiesce_hidden(&mut state.core); + Ok(WlOverlayCmd::Revive(key)) => { + revive_key(&mut state.ended, key); } Err(crossbeam_channel::TryRecvError::Empty) => break, Err(crossbeam_channel::TryRecvError::Disconnected) => { @@ -367,16 +643,40 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { // applies the new state at dt=0, avoiding a jump proportional to how // long the loop was parked. if let Some(timeout_kind) = timed_out { - let dt = match timeout_kind { - WlWait::Frame => elapsed.min(0.05), - WlWait::Deadline(_) => elapsed, - WlWait::Block => unreachable!("a blocking receive cannot time out"), - }; - state.core.tick_motion(dt); - dirty = true; + match timeout_kind { + WlWait::Frame => { + tick_all_cores(&mut state.cores, elapsed.min(0.05)); + dirty = true; + } + WlWait::Deadline(_) => { + tick_all_cores(&mut state.cores, elapsed); + dirty = true; + } + WlWait::Maintenance(_) => { + let before: HashMap = state + .cores + .iter() + .map(|(key, core)| (key.clone(), core.idle_alpha)) + .collect(); + tick_all_cores(&mut state.cores, elapsed); + dirty |= state.cores.iter().any(|(key, core)| { + before + .get(key) + .is_none_or(|alpha| *alpha != core.idle_alpha) + || needs_frame_tick(core) + }); + + let topology_was_dirty = state.topology_dirty; + state.topology_dirty = false; + queue.roundtrip(&mut state)?; + ensure_output_resources(&mut state, &qh); + dirty |= topology_was_dirty || state.topology_dirty; + state.topology_dirty = false; + } + } } - let next_frame_tick_needed = needs_frame_tick(&state.core); - if state.configured && (dirty || frame_tick_needed || next_frame_tick_needed) { + let next_frame_tick_needed = any_core_needs_frame_tick(&state.cores); + if dirty || frame_tick_needed || next_frame_tick_needed { redraw(&mut state, &shm, &qh)?; // Flush the committed frame and dispatch wl_buffer.release before // parking. The buffer map remains authoritative until release, so @@ -386,11 +686,8 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { frame_tick_needed = next_frame_tick_needed; } - if let Some(ls) = state.layer_surface.take() { - ls.destroy(); - } - if let Some(s) = state.surface.take() { - s.destroy(); + for output in state.outputs.values_mut() { + output.destroy_surfaces(); } queue.roundtrip(&mut state)?; Ok(()) @@ -398,9 +695,9 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WlWait { - Block, Frame, Deadline(Duration), + Maintenance(Duration), } enum WlWake { @@ -411,30 +708,52 @@ enum WlWake { fn wait_for_work(rx: &Receiver, wait: WlWait) -> WlWake { match wait { - WlWait::Block => match rx.recv() { - Ok(cmd) => WlWake::Command(cmd), - Err(_) => WlWake::Disconnected, - }, WlWait::Frame => match rx.recv_timeout(Duration::from_millis(16)) { Ok(cmd) => WlWake::Command(cmd), Err(crossbeam_channel::RecvTimeoutError::Timeout) => WlWake::Timeout, Err(crossbeam_channel::RecvTimeoutError::Disconnected) => WlWake::Disconnected, }, - WlWait::Deadline(timeout) => match rx.recv_timeout(timeout) { - Ok(cmd) => WlWake::Command(cmd), - Err(crossbeam_channel::RecvTimeoutError::Timeout) => WlWake::Timeout, - Err(crossbeam_channel::RecvTimeoutError::Disconnected) => WlWake::Disconnected, - }, + WlWait::Deadline(timeout) | WlWait::Maintenance(timeout) => { + match rx.recv_timeout(timeout) { + Ok(cmd) => WlWake::Command(cmd), + Err(crossbeam_channel::RecvTimeoutError::Timeout) => WlWake::Timeout, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => WlWake::Disconnected, + } + } } } -fn next_wait(core: &RenderStateCore, frame_tick_needed: bool) -> WlWait { - if frame_tick_needed || needs_frame_tick(core) { +const TOPOLOGY_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(1); + +fn next_wait( + cores: &HashMap, + frame_tick_needed: bool, + topology_dirty: bool, +) -> WlWait { + if topology_dirty { + return WlWait::Maintenance(Duration::ZERO); + } + if frame_tick_needed || any_core_needs_frame_tick(cores) { return WlWait::Frame; } - idle_fade_wait(core) - .map(WlWait::Deadline) - .unwrap_or(WlWait::Block) + match earliest_idle_fade_wait(cores) { + Some(wait) if wait <= TOPOLOGY_MAINTENANCE_INTERVAL => WlWait::Deadline(wait), + _ => WlWait::Maintenance(TOPOLOGY_MAINTENANCE_INTERVAL), + } +} + +fn any_core_needs_frame_tick(cores: &HashMap) -> bool { + cores.values().any(needs_frame_tick) +} + +fn earliest_idle_fade_wait(cores: &HashMap) -> Option { + cores.values().filter_map(idle_fade_wait).min() +} + +fn tick_all_cores(cores: &mut HashMap, dt: f64) { + for core in cores.values_mut() { + core.tick_motion(dt); + } } fn needs_frame_tick(core: &RenderStateCore) -> bool { @@ -472,8 +791,8 @@ fn quiesce_hidden(core: &mut RenderStateCore) { core.click_t = None; } -/// Render one cursor frame into a fresh wl_shm ARGB8888 buffer and attach -/// it to the layer surface. +/// Composite all visible cursor cores into their selected output and attach a +/// transparent clearing frame to every previously painted output now empty. /// /// Pipeline: /// 1. Allocate a memfd-backed wl_shm pool sized at output_w × output_h. @@ -485,34 +804,66 @@ fn quiesce_hidden(core: &mut RenderStateCore) { /// in `ext_screencopy::encode_buffer_to_png`. /// 4. Attach + damage + commit on the layer surface. /// -/// When the cursor is hidden (`core.visible == false`, idle-faded, or -/// off-screen sentinel) the pixmap is all zeros — the surface remains -/// transparent and click-through. +/// Hidden, idle-faded, or off-screen cores paint nothing. fn redraw( state: &mut OverlayState, shm: &WlShm, qh: &QueueHandle, ) -> anyhow::Result<()> { - let Some(surface) = state.surface.as_ref() else { - return Ok(()); - }; - let w = state.output_w.max(1); - let h = state.output_h.max(1); - let stride = w as i32 * 4; - let size = (stride as usize) * (h as usize); + let layouts: Vec = state + .outputs + .iter() + .filter_map(|(&id, output)| output.layout(id)) + .collect(); + let cursor_positions = state + .cores + .values() + .filter(|core| core.visible && core.pos.0 >= -100.0 && core.idle_alpha >= 0.004) + .map(|core| core.pos); + let (selected, targets) = frame_plan( + &layouts, + &state.painted_outputs, + &state.initialized_outputs, + cursor_positions, + ); - // Reuses the same anon_shm pattern as the screencopy path in mod.rs. - let (fd, ptr) = - super::anon_shm(size).map_err(|e| anyhow::anyhow!("overlay shm allocation failed: {e}"))?; + for target in targets { + redraw_output(state, shm, qh, target, &layouts)?; + } - // SAFETY: ptr came from mmap of `size` bytes, lifetime bounded to this - // function. - let pixels: &mut [u8] = unsafe { std::slice::from_raw_parts_mut(ptr as *mut u8, size) }; + state.painted_outputs = selected; + Ok(()) +} - // Paint the cursor into a tiny_skia pixmap. paint_cursor early-returns - // when the cursor is hidden / off-screen / idle-faded, so the pixmap - // is left fully transparent in those cases (which is also what we want - // for the click-through layer surface). +fn redraw_output( + state: &mut OverlayState, + shm: &WlShm, + qh: &QueueHandle, + target: FrameTarget, + layouts: &[OutputLayout], +) -> anyhow::Result<()> { + let Some((surface, layout)) = state + .outputs + .get(&target.id) + .and_then(|output| Some((output.surface.clone()?, output.layout(target.id)?))) + else { + return Ok(()); + }; + let w = layout.width.max(1); + let h = layout.height.max(1); + let stride = w + .checked_mul(4) + .and_then(|stride| i32::try_from(stride).ok()) + .ok_or_else(|| anyhow::anyhow!("overlay output {w}x{h} has an invalid stride"))?; + let size = usize::try_from(stride) + .ok() + .and_then(|stride| stride.checked_mul(h as usize)) + .ok_or_else(|| anyhow::anyhow!("overlay output {w}x{h} buffer size overflow"))?; + + // A clearing target intentionally stays transparent. Each cursor selected + // for this output subtracts its logical compositor origin from the global + // position; this is the same origin contract used by the Windows virtual + // desktop. let pm_result = tiny_skia::Pixmap::new(w, h); let mut pm = match pm_result { Some(p) => p, @@ -525,10 +876,31 @@ fn redraw( "tiny_skia::Pixmap::new({w}, {h}) failed — out of memory for the overlay buffer" ), }; - // backing_scale=1.0 matches the X11 path; per-output Wayland scale is - // a follow-up (would consume `wl_output.scale` and `preferred_buffer - // _scale` from wl_surface v6). - cursor_overlay::paint_cursor(&mut pm, &state.core, 0.0, 0.0, None, 1.0); + + // Reuses the same anon_shm pattern as the screencopy path in mod.rs. The + // pixmap is allocated first so a tiny-skia OOM cannot strand this mmap/fd. + let (fd, ptr) = + super::anon_shm(size).map_err(|e| anyhow::anyhow!("overlay shm allocation failed: {e}"))?; + + // SAFETY: ptr came from mmap of `size` bytes and is transferred to + // `pending_buffers` before this function returns successfully. + let pixels: &mut [u8] = unsafe { std::slice::from_raw_parts_mut(ptr as *mut u8, size) }; + let mut painted_positions = Vec::new(); + { + // HashMap iteration is intentionally normalized by key so overlapping + // named cursors composite deterministically from frame to frame. + for (_, core) in visible_cores_for_output(&state.cores, layouts, target.id) { + cursor_overlay::paint_cursor( + &mut pm, + core, + f64::from(layout.origin_x), + f64::from(layout.origin_y), + None, + 1.0, + ); + painted_positions.push(core.pos); + } + } // CUA_OVERLAY_DEBUG=1 paints a 60x60 magenta square at the cursor's // current pos on top of the gradient arrow. Useful when validating @@ -536,22 +908,23 @@ fn redraw( // small at native scale and easy to miss in a screenshot, while the // magenta block is impossible to miss. if std::env::var_os("CUA_OVERLAY_DEBUG").is_some() { - let (cx, cy) = state.core.pos; - let cx = cx as i32; - let cy = cy as i32; - let half = 30i32; - for dy in -half..half { - for dx in -half..half { - let px = cx + dx; - let py = cy + dy; - if px < 0 || py < 0 || px >= w as i32 || py >= h as i32 { - continue; + for &(cursor_x, cursor_y) in &painted_positions { + let cx = (cursor_x - f64::from(layout.origin_x)) as i32; + let cy = (cursor_y - f64::from(layout.origin_y)) as i32; + let half = 30i32; + for dy in -half..half { + for dx in -half..half { + let px = cx + dx; + let py = cy + dy; + if px < 0 || py < 0 || px >= w as i32 || py >= h as i32 { + continue; + } + let off = ((py as usize) * (w as usize) + (px as usize)) * 4; + pm.data_mut()[off] = 0xFF; // R + pm.data_mut()[off + 1] = 0x00; // G + pm.data_mut()[off + 2] = 0xFF; // B + pm.data_mut()[off + 3] = 0xFF; // A } - let off = ((py as usize) * (w as usize) + (px as usize)) * 4; - pm.data_mut()[off] = 0xFF; // R - pm.data_mut()[off + 1] = 0x00; // G - pm.data_mut()[off + 2] = 0xFF; // B - pm.data_mut()[off + 3] = 0xFF; // A } } } @@ -588,16 +961,30 @@ fn redraw( state.pending_buffers.insert(buffer_id, (ptr, size, fd)); dbg(&format!( - "redraw w={w} h={h} stride={stride} buf_id={buffer_id} pos=({:.1},{:.1}) visible={}", - state.core.pos.0, state.core.pos.1, state.core.visible + "redraw output={} origin=({}, {}) w={w} h={h} stride={stride} buf_id={buffer_id} cursors={}", + output_label(state, target.id), + layout.origin_x, + layout.origin_y, + painted_positions.len(), )); surface.attach(Some(&buffer), 0, 0); surface.damage_buffer(0, 0, w as i32, h as i32); surface.commit(); + // Mark initialized only after the buffer attach + surface commit succeed. + // An early return above leaves the output pending for the next plan. + state.initialized_outputs.insert(target.id); pool.destroy(); Ok(()) } +fn output_label(state: &OverlayState, id: u32) -> String { + state + .outputs + .get(&id) + .and_then(|output| output.name.clone()) + .unwrap_or_else(|| format!("wl_output#{id}")) +} + // ── Wayland Dispatch impls ─────────────────────────────────────────────── impl Dispatch for OverlayState { @@ -609,32 +996,68 @@ impl Dispatch for OverlayState { _conn: &Connection, qh: &QueueHandle, ) { - if let wl_registry::Event::Global { - name, - interface, - version, - } = event - { - match interface.as_str() { - "wl_compositor" => { - state.compositor = - Some(registry.bind::(name, version.min(6), qh, ())); - } - "wl_shm" => { - state.shm = Some(registry.bind::(name, version.min(1), qh, ())); - } - "wl_output" => { - if state.output.is_none() { - state.output = - Some(registry.bind::(name, version.min(4), qh, ())); + match event { + wl_registry::Event::Global { + name, + interface, + version, + } => { + match interface.as_str() { + "wl_compositor" => { + state.compositor = + Some(registry.bind::(name, version.min(6), qh, ())); + } + "wl_shm" => { + state.shm = + Some(registry.bind::(name, version.min(1), qh, ())); + } + "wl_output" => { + let output = registry.bind::( + name, + version.min(4), + qh, + OutputData { id: name }, + ); + state.outputs.insert(name, NativeOutput::new(output)); + state.topology_dirty = true; + } + "zwlr_layer_shell_v1" => { + state.layer_shell = Some(registry.bind::( + name, + version.min(4), + qh, + (), + )); } + "zxdg_output_manager_v1" => { + state.xdg_output_manager = + Some(registry.bind::( + name, + version.min(3), + qh, + (), + )); + } + _ => {} } - "zwlr_layer_shell_v1" => { - state.layer_shell = - Some(registry.bind::(name, version.min(4), qh, ())); + ensure_output_resources(state, qh); + } + wl_registry::Event::GlobalRemove { name } => { + state.painted_outputs.remove(&name); + state.initialized_outputs.remove(&name); + if let Some(mut output) = state.outputs.remove(&name) { + output.destroy_surfaces(); + // wl_output.release was added in v3. On an older generic + // wlroots compositor, dropping the client proxy is the only + // valid cleanup; issuing the newer request would be a + // protocol error. + if output.output.version() >= 3 { + output.output.release(); + } + state.topology_dirty = true; } - _ => {} } + _ => {} } } } @@ -663,21 +1086,77 @@ impl Dispatch for OverlayState { } } -impl Dispatch for OverlayState { +impl Dispatch for OverlayState { fn event( state: &mut Self, _: &WlOutput, event: ::Event, - _: &(), + data: &OutputData, _: &Connection, _: &QueueHandle, ) { use wayland_client::protocol::wl_output; - if let wl_output::Event::Mode { width, height, .. } = event { - if width > 0 && height > 0 { - state.output_w = width as u32; - state.output_h = height as u32; + let Some(output) = state.outputs.get_mut(&data.id) else { + return; + }; + match event { + wl_output::Event::Geometry { x, y, .. } => { + state.topology_dirty |= output.wl_origin != Some((x, y)); + output.wl_origin = Some((x, y)); + } + wl_output::Event::Mode { width, height, .. } if width > 0 && height > 0 => { + state.topology_dirty |= output.mode_size != Some((width as u32, height as u32)); + output.mode_size = Some((width as u32, height as u32)); + } + wl_output::Event::Scale { factor } => { + state.topology_dirty |= output.scale != factor.max(1); + output.scale = factor.max(1); + } + wl_output::Event::Name { name } => { + output.name = Some(name); + } + _ => {} + } + } +} + +impl Dispatch for OverlayState { + fn event( + _: &mut Self, + _: &ZxdgOutputManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for OverlayState { + fn event( + state: &mut Self, + _: &ZxdgOutputV1, + event: ::Event, + data: &OutputData, + _: &Connection, + _: &QueueHandle, + ) { + let Some(output) = state.outputs.get_mut(&data.id) else { + return; + }; + match event { + zxdg_output_v1::Event::LogicalPosition { x, y } => { + state.topology_dirty |= output.logical_origin != Some((x, y)); + output.logical_origin = Some((x, y)); + } + zxdg_output_v1::Event::LogicalSize { width, height } if width > 0 && height > 0 => { + state.topology_dirty |= output.logical_size != Some((width as u32, height as u32)); + output.logical_size = Some((width as u32, height as u32)); } + zxdg_output_v1::Event::Name { name } => { + output.name = Some(name); + } + _ => {} } } } @@ -694,29 +1173,47 @@ impl Dispatch for OverlayState { } } -impl Dispatch for OverlayState { +impl Dispatch for OverlayState { fn event( state: &mut Self, layer: &ZwlrLayerSurfaceV1, event: ::Event, - _: &(), + data: &LayerData, _: &Connection, _: &QueueHandle, ) { - if let zwlr_layer_surface_v1::Event::Configure { - serial, - width, - height, - } = event - { - layer.ack_configure(serial); - if width > 0 { - state.output_w = width; + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + layer.ack_configure(serial); + if let Some(output) = state.outputs.get_mut(&data.id) { + output.closed = false; + let fallback = output.logical_size.or(output.mode_size).unwrap_or((1, 1)); + let configured_size = ( + if width > 0 { width } else { fallback.0 }, + if height > 0 { height } else { fallback.1 }, + ); + // Each configure starts a new layer-surface buffer cycle. + // Queue exactly one matching clear/paint frame even when + // the compositor repeats the same logical size. + state.initialized_outputs.remove(&data.id); + state.topology_dirty = true; + output.configured_size = Some(configured_size); + } } - if height > 0 { - state.output_h = height; + zwlr_layer_surface_v1::Event::Closed => { + state.painted_outputs.remove(&data.id); + state.initialized_outputs.remove(&data.id); + if let Some(output) = state.outputs.get_mut(&data.id) { + output.closed = true; + output.close_layer(); + state.topology_dirty = true; + } } - state.configured = true; + _ => {} } } } @@ -798,22 +1295,225 @@ mod tests { core } + fn three_monitor_layout() -> Vec { + vec![ + // 1920x1080 logical laptop panel at 2x backing scale. + OutputLayout { + id: 1, + origin_x: 0, + origin_y: 0, + width: 1920, + height: 1080, + }, + // 2560x1440 logical external display, raised above the laptop. + OutputLayout { + id: 2, + origin_x: 1920, + origin_y: -200, + width: 2560, + height: 1440, + }, + // Fractionally scaled portrait display left of the laptop. + OutputLayout { + id: 3, + origin_x: -1280, + origin_y: 56, + width: 1280, + height: 1024, + }, + ] + } + + fn initialized(layouts: &[OutputLayout]) -> HashSet { + layouts.iter().map(|layout| layout.id).collect() + } + + #[test] + fn selects_output_and_converts_global_to_output_local_coordinates() { + let layouts = three_monitor_layout(); + assert_eq!( + select_output(&layouts, 2500.0, 100.0), + Some(SelectedOutput { + id: 2, + local_x: 580.0, + local_y: 300.0, + }) + ); + assert_eq!( + select_output(&layouts, -1000.0, 256.0), + Some(SelectedOutput { + id: 3, + local_x: 280.0, + local_y: 200.0, + }) + ); + assert_eq!( + select_output(&layouts, 1919.0, 500.0).map(|output| output.id), + Some(1) + ); + assert_eq!( + select_output(&layouts, 1920.0, 500.0).map(|output| output.id), + Some(2) + ); + assert_eq!(select_output(&layouts, 5000.0, 5000.0), None); + } + + #[test] + fn overlapping_outputs_use_the_same_deterministic_selection_for_compositing() { + let layouts = vec![ + OutputLayout { + id: 8, + origin_x: 0, + origin_y: 0, + width: 1920, + height: 1080, + }, + OutputLayout { + id: 4, + origin_x: 0, + origin_y: 0, + width: 1920, + height: 1080, + }, + ]; + let mut core = positioned_core(); + core.pos = (400.0, 300.0); + let cores = HashMap::from([("session".to_owned(), core)]); + + assert_eq!(select_output(&layouts, 400.0, 300.0).unwrap().id, 4); + assert_eq!(visible_cores_for_output(&cores, &layouts, 4).len(), 1); + assert!(visible_cores_for_output(&cores, &layouts, 8).is_empty()); + } + + #[test] + fn initial_configured_outputs_each_plan_one_transparent_frame() { + let layouts = three_monitor_layout(); + let (selected, targets) = frame_plan( + &layouts, + &HashSet::new(), + &HashSet::new(), + std::iter::empty::<(f64, f64)>(), + ); + + assert!(selected.is_empty()); + assert_eq!( + targets.iter().map(|target| target.id).collect::>(), + vec![1, 2, 3] + ); + } + + #[test] + fn initialized_stable_outputs_plan_no_idle_frame() { + let layouts = three_monitor_layout(); + let (selected, targets) = frame_plan( + &layouts, + &HashSet::new(), + &initialized(&layouts), + std::iter::empty::<(f64, f64)>(), + ); + + assert!(selected.is_empty()); + assert!(targets.is_empty()); + } + + #[test] + fn hotplugged_output_plans_one_initial_transparent_frame() { + let layouts = three_monitor_layout(); + let already_initialized = HashSet::from([1, 2]); + let (selected, targets) = frame_plan( + &layouts, + &HashSet::new(), + &already_initialized, + std::iter::empty::<(f64, f64)>(), + ); + + assert!(selected.is_empty()); + assert_eq!(targets, vec![FrameTarget { id: 3 }]); + } + + #[test] + fn selected_output_combines_initialization_and_cursor_paint_in_one_frame() { + let layouts = three_monitor_layout(); + let already_initialized = HashSet::from([1, 3]); + let (selected, targets) = frame_plan( + &layouts, + &HashSet::new(), + &already_initialized, + Some((2500.0, 100.0)), + ); + + assert_eq!(selected, HashSet::from([2])); + assert_eq!(targets, vec![FrameTarget { id: 2 }]); + } + #[test] - fn fresh_sentinel_overlay_blocks_without_frame_polling() { + fn crossing_outputs_clears_the_old_surface_and_paints_the_new_one() { + let layouts = three_monitor_layout(); + let painted = HashSet::from([1]); + let (selected, targets) = frame_plan( + &layouts, + &painted, + &initialized(&layouts), + Some((2500.0, 100.0)), + ); + + assert_eq!(selected, HashSet::from([2])); + assert_eq!(targets, vec![FrameTarget { id: 1 }, FrameTarget { id: 2 }]); + } + + #[test] + fn hide_or_session_removal_clears_every_previously_painted_output() { + let layouts = three_monitor_layout(); + let painted = HashSet::from([1, 2, 3]); + let (selected, targets) = frame_plan( + &layouts, + &painted, + &initialized(&layouts), + std::iter::empty::<(f64, f64)>(), + ); + + assert!(selected.is_empty()); + assert_eq!(targets.len(), 3); + assert_eq!( + targets.iter().map(|target| target.id).collect::>(), + vec![1, 2, 3] + ); + } + + #[test] + fn fresh_sentinel_overlay_uses_only_topology_maintenance() { + let layouts = three_monitor_layout(); let core = RenderStateCore::new(CursorConfig::default()); - assert_eq!(next_wait(&core, false), WlWait::Block); - assert!(!needs_frame_tick(&core)); + let cores = HashMap::from([("default".to_owned(), core)]); + assert_eq!( + next_wait(&cores, false, false), + WlWait::Maintenance(TOPOLOGY_MAINTENANCE_INTERVAL) + ); + assert!(!any_core_needs_frame_tick(&cores)); + assert_eq!( + next_wait(&cores, false, true), + WlWait::Maintenance(Duration::ZERO) + ); + assert!(frame_plan( + &layouts, + &HashSet::new(), + &initialized(&layouts), + std::iter::empty::<(f64, f64)>() + ) + .1 + .is_empty()); } #[test] fn stable_visible_overlay_sleeps_until_idle_fade_deadline() { let mut core = positioned_core(); core.idle_secs = 0.25; + let cores = HashMap::from([("cursor-a".to_owned(), core)]); assert_eq!( - next_wait(&core, false), + next_wait(&cores, false, false), WlWait::Deadline(Duration::from_millis(750)) ); - assert!(!needs_frame_tick(&core)); + assert!(!any_core_needs_frame_tick(&cores)); } #[test] @@ -821,13 +1521,15 @@ mod tests { let mut core = positioned_core(); core.click_t = Some(0.0); assert!(needs_frame_tick(&core)); - assert_eq!(next_wait(&core, false), WlWait::Frame); + let mut cores = HashMap::from([("cursor-a".to_owned(), core)]); + assert_eq!(next_wait(&cores, false, false), WlWait::Frame); + let core = cores.get_mut("cursor-a").unwrap(); core.click_t = None; core.idle_secs = 1.0; core.idle_alpha = 1.0; - assert!(needs_frame_tick(&core)); - assert_eq!(next_wait(&core, false), WlWait::Frame); + assert!(needs_frame_tick(core)); + assert_eq!(next_wait(&cores, false, false), WlWait::Frame); } #[test] @@ -839,22 +1541,189 @@ mod tests { assert!(core.click_t.is_none()); assert!(core.path.is_none()); assert!(core.spring.is_none()); - assert_eq!(next_wait(&core, false), WlWait::Block); + let cores = HashMap::from([("cursor-a".to_owned(), core)]); + assert_eq!( + next_wait(&cores, false, false), + WlWait::Maintenance(TOPOLOGY_MAINTENANCE_INTERVAL) + ); + } + + #[test] + fn forward_mapping_preserves_named_cursor_keys() { + let snap = message(OverlayCommand::SnapTo { + x: 10.0, + y: 20.0, + heading_radians: None, + }); + assert!(matches!( + map_overlay_msg(&snap), + Some(WlOverlayCmd::Cmd { key, .. }) if key == "test" + )); + assert!(matches!( + map_overlay_msg(&OverlayMsg::Remove("session-a".to_owned())), + Some(WlOverlayCmd::Remove(key)) if key == "session-a" + )); + assert!(matches!( + map_overlay_msg(&OverlayMsg::Revive("session-a".to_owned())), + Some(WlOverlayCmd::Revive(key)) if key == "session-a" + )); + } + + #[test] + fn named_cursors_render_on_independent_outputs_and_removal_clears_only_one() { + let template = CursorConfig::default(); + let mut cores = HashMap::new(); + let mut ended = HashSet::new(); + assert!(apply_keyed_command( + &mut cores, + &template, + &ended, + "session-a".to_owned(), + OverlayCommand::SnapTo { + x: 100.0, + y: 100.0, + heading_radians: None, + }, + )); + assert!(apply_keyed_command( + &mut cores, + &template, + &ended, + "session-b".to_owned(), + OverlayCommand::SnapTo { + x: 2500.0, + y: 100.0, + heading_radians: None, + }, + )); + assert_eq!(cores["session-a"].cfg.cursor_id, "session-a"); + assert_eq!(cores["session-b"].cfg.cursor_id, "session-b"); + + let layouts = three_monitor_layout(); + assert_eq!( + visible_cores_for_output(&cores, &layouts, 1) + .into_iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + vec!["session-a"] + ); + assert_eq!( + visible_cores_for_output(&cores, &layouts, 2) + .into_iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + vec!["session-b"] + ); + let (painted, targets) = frame_plan( + &layouts, + &HashSet::new(), + &initialized(&layouts), + cores.values().map(|core| core.pos), + ); + assert_eq!(painted, HashSet::from([1, 2])); + assert_eq!(targets, vec![FrameTarget { id: 1 }, FrameTarget { id: 2 }]); + + assert!(remove_keyed_core( + &mut cores, + &mut ended, + "session-a".to_owned() + )); + assert!(!cores.contains_key("session-a")); + assert!(cores.contains_key("session-b")); + assert!(visible_cores_for_output(&cores, &layouts, 1).is_empty()); + assert_eq!( + visible_cores_for_output(&cores, &layouts, 2) + .into_iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + vec!["session-b"] + ); + let (selected, targets) = frame_plan( + &layouts, + &painted, + &initialized(&layouts), + cores.values().map(|core| core.pos), + ); + assert_eq!(selected, HashSet::from([2])); + assert_eq!(targets, vec![FrameTarget { id: 1 }, FrameTarget { id: 2 }]); + + // A queued command cannot resurrect an ended named session. + assert!(!apply_keyed_command( + &mut cores, + &template, + &ended, + "session-a".to_owned(), + OverlayCommand::SnapTo { + x: 200.0, + y: 200.0, + heading_radians: None, + }, + )); + assert!(!cores.contains_key("session-a")); + + revive_key(&mut ended, "session-a".to_owned()); + assert!(!ended.contains("session-a")); + assert!(apply_keyed_command( + &mut cores, + &template, + &ended, + "session-a".to_owned(), + OverlayCommand::SnapTo { + x: 200.0, + y: 200.0, + heading_radians: None, + }, + )); + assert!(cores.contains_key("session-a")); + } + + #[test] + fn named_cursors_schedule_animation_and_idle_deadlines_independently() { + let mut idle = positioned_core(); + idle.idle_secs = 0.25; + let mut animated = positioned_core(); + animated.motion.idle_hide_ms = 4_000.0; + animated.click_t = Some(0.0); + let mut cores = + HashMap::from([("idle".to_owned(), idle), ("animated".to_owned(), animated)]); + + assert_eq!(next_wait(&cores, false, false), WlWait::Frame); + cores.get_mut("animated").unwrap().click_t = None; + assert_eq!( + next_wait(&cores, false, false), + WlWait::Deadline(Duration::from_millis(750)) + ); + } + + #[test] + fn default_cursor_is_not_removed_or_marked_ended() { + let mut cores = HashMap::from([( + "default".to_owned(), + RenderStateCore::new(CursorConfig::default()), + )]); + let mut ended = HashSet::new(); + assert!(!remove_keyed_core( + &mut cores, + &mut ended, + "default".to_owned() + )); + assert!(cores.contains_key("default")); + assert!(!ended.contains("default")); } #[test] - fn blocked_scheduler_wakes_on_command_arrival() { + fn maintenance_scheduler_wakes_immediately_on_command_arrival() { let (tx, rx) = bounded(1); - tx.send(WlOverlayCmd::Remove).unwrap(); + tx.send(WlOverlayCmd::Remove("test".to_owned())).unwrap(); assert!(matches!( - wait_for_work(&rx, WlWait::Block), - WlWake::Command(WlOverlayCmd::Remove) + wait_for_work(&rx, WlWait::Maintenance(Duration::from_secs(1))), + WlWake::Command(WlOverlayCmd::Remove(key)) if key == "test" )); } #[test] fn disabled_config_refuses_forwarding_and_thread_startup() { - set_config_enabled(false); + CONFIG_ENABLED.store(false, Ordering::Release); let msg = message(OverlayCommand::SnapTo { x: 10.0, y: 20.0, From 3e84ef933168eced8319cf51dedebd830fdda0c0 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 14:05:03 -0500 Subject: [PATCH 106/117] docs(cua-driver): clarify persistent GUI sessions --- .../rust/Skills/cua-driver/SKILL.md | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 84bbc03a6c..23908f7c8a 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -185,11 +185,17 @@ or a public session label. ## GUI transport defaults — prefer cua-driver over GUI shell shims -**Default transport is the `cua-driver` CLI** — `Bash` shelling out -to `cua-driver ''`. MCP tools (prefix -`mcp__cua-driver__*`) only when the user explicitly asks for them. -CLI wins because it picks up rebuilds instantly, failures are -easier to diagnose, and there's no per-tool schema-load overhead. +**Default transport is the `cua-driver` CLI for one-off calls** — `Bash` +shelling out to `cua-driver ''`. Each CLI invocation +owns a disposable transport session that is cleaned up after its response. +Use one persistent `cua-driver mcp` connection for a multi-call GUI workflow +that needs shared cursor, recording, browser, or named-session state. A public +session label is not a credential and a later one-shot process cannot adopt +the previous process's lifecycle merely by repeating that label. + +CLI wins for isolated inspection and management because it picks up rebuilds +instantly, failures are easier to diagnose, and there's no per-tool +schema-load overhead. Persistent MCP wins for an ordered action loop. Every reference to `click(...)`, `get_window_state(...)` etc. in this skill means `cua-driver click '{...}'` — translate to MCP form only @@ -237,18 +243,19 @@ Tool names are `snake_case`, management subcommands are - `cua-driver recording start|stop|status` — see `RECORDING.md` - `cua-driver check-update [--json] [--no-cache]` — read-only "is a newer release available?" probe. Same payload as the `check_for_update` MCP tool; pair with `cua-driver update --apply` to install. -Canonical multi-step workflow (example shape — platform-specific -launch idioms in the per-OS companion file): +Canonical multi-step workflow within one persistent MCP connection (example +shape — platform-specific launch idioms in the per-OS companion file): ```bash -cua-driver serve -cua-driver launch_app '{"bundle_id":"..."}' +# Start the service once, then connect one MCP client with `cua-driver mcp`. +# The calls below are tool calls on that same connection, not separate shell +# invocations of `cua-driver `. +launch_app({"bundle_id":"..."}) # → {pid: 844, windows: [{window_id: 10725, ...}]} -cua-driver get_window_state '{"pid":844,"window_id":10725}' +get_window_state({"pid":844,"window_id":10725}) # Use the returned structuredContent.elements[].element_token: -cua-driver click '{"pid":844,"element_token":"s0000002a:14"}' -cua-driver verify_state '{"pid":844,"window_id":10725,"expect":[{"element":{"selector":{"label_contains":"Saved"},"exists":true}}]}' -cua-driver stop +click({"pid":844,"element_token":"s0000002a:14"}) +verify_state({"pid":844,"window_id":10725,"expect":[{"element":{"selector":{"label_contains":"Saved"},"exists":true}}]}) ``` For Chromium page content, keep the same native window selection but switch to From a303d3afa15bfa191410003319941c73a1fd2540 Mon Sep 17 00:00:00 2001 From: Zane Chee Date: Mon, 10 Aug 2026 12:34:53 +0800 Subject: [PATCH 107/117] fix(cua-driver): render cursors at negative display coordinates --- .../examples/export_gallery_frames.rs | 13 +- .../crates/cursor-overlay/src/render_state.rs | 278 +++++++++--------- .../rust/crates/platform-linux/src/overlay.rs | 130 ++++---- .../crates/platform-linux/src/tools/impl_.rs | 2 +- .../platform-linux/src/wayland/overlay.rs | 38 +-- .../platform-macos/src/cursor/overlay.rs | 85 +++--- .../platform-macos/src/tools/move_cursor.rs | 6 +- .../crates/platform-windows/src/overlay.rs | 97 +++--- .../platform-windows/src/tools/impl_.rs | 7 +- 9 files changed, 316 insertions(+), 340 deletions(-) diff --git a/libs/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs b/libs/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs index e0300b06a4..5706056928 100644 --- a/libs/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs +++ b/libs/cua-driver/rust/crates/cursor-overlay/examples/export_gallery_frames.rs @@ -137,17 +137,13 @@ fn export_state(output: &Path, state: GalleryState) { config.cursor_id = "gallery-session".into(); let mut core = RenderStateCore::new(config); core.motion.idle_hide_ms = 0.0; - core.pos = ( + core.pos = Some(( f64::from(SIZE) / (2.0 * f64::from(PREVIEW_BACKING_SCALE)), f64::from(SIZE) / (2.0 * f64::from(PREVIEW_BACKING_SCALE)), - ); + )); core.heading = f64::from(std::f32::consts::FRAC_PI_4); if let Some(session_label) = state.session_label { - core.apply_command_base( - OverlayCommand::SetSessionLabel(session_label.into()), - false, - false, - ); + core.apply_command_base(OverlayCommand::SetSessionLabel(session_label.into()), true); } core.apply_command_base( OverlayCommand::BeginAction { @@ -155,8 +151,7 @@ fn export_state(output: &Path, state: GalleryState) { delivery: state.delivery, target: state.target, }, - false, - false, + true, ); core.visual.elapsed_secs = f64::from(frame) / f64::from(FPS); let pixmap = render_frame(&core, SIZE, SIZE, 0.0, 0.0, None, PREVIEW_BACKING_SCALE); diff --git a/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs b/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs index a5505ebc50..603901d3e0 100644 --- a/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs +++ b/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs @@ -52,8 +52,8 @@ pub struct RenderStateCore { pub cfg: CursorConfig, /// Current motion / timing config (mutable via [`OverlayCommand::SetMotion`]). pub motion: MotionConfig, - /// Current rendered position in screen / overlay-window coordinates. - pub pos: (f64, f64), + /// Current rendered position, or `None` until the cursor is first placed. + pub pos: Option<(f64, f64)>, /// Visual heading in radians (tip direction = motion_dir + π). pub heading: f64, /// In-flight planned path; `None` = at rest. @@ -100,9 +100,7 @@ pub struct RenderStateCore { impl RenderStateCore { /// Build the core from a launch-time CursorConfig. - /// `pos` starts at the off-screen sentinel `(-200, -200)` to indicate - /// "never placed on screen yet" — the click path uses this to detect - /// first-placement and snap rather than animate. + /// `pos` starts empty so the first placement can snap rather than animate. pub fn new(cfg: CursorConfig) -> Self { let motion = cfg.motion.clone(); let visual = CursorVisualState { @@ -126,7 +124,7 @@ impl RenderStateCore { visual, theme, theme_fallback, - pos: (-200.0, -200.0), + pos: None, heading: std::f64::consts::FRAC_PI_4, path: None, dist: 0.0, @@ -146,8 +144,9 @@ impl RenderStateCore { } } - fn cursor_is_revealed(&self) -> bool { - self.visible && self.pos.0 >= -100.0 && self.idle_alpha >= 0.004 + /// Return whether cursor pixels and related UI may be shown. + pub fn cursor_is_revealed(&self) -> bool { + self.visible && self.pos.is_some() && self.idle_alpha >= 0.004 } fn reveal_session_badge(&mut self) { @@ -207,36 +206,39 @@ impl RenderStateCore { /// Update hover state from a platform-native hardware pointer sample. /// - /// `self.pos` is the centre of the cursor artwork. The hit radius is a + /// The placed position is the centre of the cursor artwork. The hit radius is a /// little larger than the 42 point production artwork so the interaction /// remains comfortable around the white outline and glow. pub fn update_session_badge_hover(&mut self, pointer: Option<(f64, f64)>) -> bool { const HOVER_RADIUS: f64 = crate::theme::DISPLAY_SIZE as f64 * 0.82; let hovered = self.session_badge_needs_hover_poll() - && pointer.is_some_and(|(x, y)| { - let dx = x - self.pos.0; - let dy = y - self.pos.1; - if dx * dx + dy * dy <= HOVER_RADIUS * HOVER_RADIUS { - return true; - } - crate::session_badge_layout(crate::SessionBadgeInput { - label: self.session_label.as_deref(), - delivery: self.badge_modifiers.and_then(|modifiers| modifiers.0), - target: self.badge_modifiers.and_then(|modifiers| modifiers.1), - cursor: (self.pos.0 as f32, self.pos.1 as f32), - backing_scale: 1.0, - label_alpha: self.session_badge_alpha(), - chip_alpha: self.session_badge_chip_alpha(), - clip: None, - }) - .is_some_and(|layout| { - let rect = layout.rect; - x >= rect.x() as f64 - && x <= (rect.x() + rect.width()) as f64 - && y >= rect.y() as f64 - && y <= (rect.y() + rect.height()) as f64 - }) - }); + && self + .pos + .zip(pointer) + .is_some_and(|((cursor_x, cursor_y), (x, y))| { + let dx = x - cursor_x; + let dy = y - cursor_y; + if dx * dx + dy * dy <= HOVER_RADIUS * HOVER_RADIUS { + return true; + } + crate::session_badge_layout(crate::SessionBadgeInput { + label: self.session_label.as_deref(), + delivery: self.badge_modifiers.and_then(|modifiers| modifiers.0), + target: self.badge_modifiers.and_then(|modifiers| modifiers.1), + cursor: (cursor_x as f32, cursor_y as f32), + backing_scale: 1.0, + label_alpha: self.session_badge_alpha(), + chip_alpha: self.session_badge_chip_alpha(), + clip: None, + }) + .is_some_and(|layout| { + let rect = layout.rect; + x >= rect.x() as f64 + && x <= (rect.x() + rect.width()) as f64 + && y >= rect.y() as f64 + && y <= (rect.y() + rect.height()) as f64 + }) + }); let changed = hovered != self.session_badge_hovered; self.session_badge_hovered = hovered; changed @@ -320,14 +322,14 @@ impl RenderStateCore { vy: impulse * 0.5 * vh.sin(), }); self.spring_tgt = Some((end.x, end.y, end_heading)); - self.pos = (end.x, end.y); + self.pos = Some((end.x, end.y)); self.heading = end_heading; self.path = None; self.dist = 0.0; fire_arrival = true; } else { let s: PathState = p.sample(self.dist); - self.pos = (s.x, s.y); + self.pos = Some((s.x, s.y)); // Point the arrow exactly along the path tangent (the renderer // adds π, so we store tangent+π). Assigned directly rather than // rate-limited toward it, so the tip actually tracks the @@ -344,10 +346,10 @@ impl RenderStateCore { s.ox += s.vx * sdt; s.oy += s.vy * sdt; } - self.pos = (tx + s.ox, ty + s.oy); + self.pos = Some((tx + s.ox, ty + s.oy)); self.heading = th; if s.ox.hypot(s.oy) < 0.3 && s.vx.hypot(s.vy) < 2.0 { - self.pos = (tx, ty); + self.pos = Some((tx, ty)); self.spring = None; } else { self.spring = Some(s); @@ -432,14 +434,14 @@ impl RenderStateCore { vy: impulse * SPRING_OVERSHOOT * vh.sin(), }); self.spring_tgt = Some((end.x, end.y, end_heading)); - self.pos = (end.x, end.y); + self.pos = Some((end.x, end.y)); self.heading = end_heading; self.path = None; self.dist = 0.0; fire_arrival = true; } else { let s: PathState = p.sample(self.dist); - self.pos = (s.x, s.y); + self.pos = Some((s.x, s.y)); // Point the arrow exactly along the path tangent (renderer adds // π, so store tangent+π). Direct assignment, not rate-limited, so // the tip tracks the trajectory instead of lagging on fast moves. @@ -455,10 +457,10 @@ impl RenderStateCore { s.ox += s.vx * sdt; s.oy += s.vy * sdt; } - self.pos = (tx + s.ox, ty + s.oy); + self.pos = Some((tx + s.ox, ty + s.oy)); self.heading = th; if s.ox.hypot(s.oy) < 0.3 && s.vx.hypot(s.vy) < 2.0 { - self.pos = (tx, ty); + self.pos = Some((tx, ty)); self.spring = None; } else { self.spring = Some(s); @@ -533,22 +535,10 @@ impl RenderStateCore { /// for variants the platform must handle itself (e.g. macOS's /// `ShowFocusRect`). /// - /// `move_to_snap_sentinel` controls macOS-only behaviour: when `true`, - /// `MoveTo` snaps `self.pos` to the offset target if the cursor is - /// still at the off-screen sentinel (`pos.0 < -50.0`). Windows/Linux - /// pass `false` here. - /// - /// `click_pulse_sentinel_only` likewise controls macOS-only behaviour: - /// when `true`, `ClickPulse` only updates `self.pos` if the cursor is - /// still at the sentinel (the animation already landed it there - /// otherwise). Windows/Linux pass `false`, which always snaps - /// `self.pos` to the click point. - pub fn apply_command_base( - &mut self, - cmd: OverlayCommand, - move_to_snap_sentinel: bool, - click_pulse_sentinel_only: bool, - ) -> bool { + /// `click_repositions` is false on macOS, where a completed glide already + /// owns the cursor position, and true on Windows/Linux, where click pulses + /// snap to their command coordinates. + pub fn apply_command_base(&mut self, cmd: OverlayCommand, click_repositions: bool) -> bool { match cmd { OverlayCommand::MoveTo { x, @@ -565,12 +555,8 @@ impl RenderStateCore { let tx = x + end_heading_radians.cos() * CLICK_OFFSET; let ty = y + end_heading_radians.sin() * CLICK_OFFSET; - // macOS-only: if the cursor is still at the initial off-screen - // sentinel, snap it to the offset target so the path starts on-screen. - if move_to_snap_sentinel && self.pos.0 < -50.0 { - self.pos = (tx, ty); - } - let (x0, y0) = self.pos; + let (x0, y0) = self.pos.unwrap_or((tx, ty)); + self.pos = Some((x0, y0)); let th0 = self.heading + std::f64::consts::PI; let th1 = end_heading_radians + std::f64::consts::PI; let plan = @@ -600,7 +586,7 @@ impl RenderStateCore { heading_radians, } => { let reveal_badge = !self.cursor_is_revealed(); - self.pos = (x, y); + self.pos = Some((x, y)); if let Some(heading) = heading_radians { self.heading = heading; } @@ -625,20 +611,20 @@ impl RenderStateCore { } OverlayCommand::ClickPulse { x, y } => { let reveal_badge = !self.cursor_is_revealed(); - if click_pulse_sentinel_only { - // macOS: only snap position on first placement (sentinel state). - // After that the cursor stays where the animation landed. - if self.pos.0 < -50.0 { - // Apply same click offset so tip lands at click point. + if click_repositions || self.pos.is_none() { + let position = if click_repositions { + (x, y) + } else { + // macOS applies the MoveTo offset on first placement + // so the cursor tip lands at the click point. const CLICK_OFFSET: f64 = 16.0; let angle = std::f64::consts::FRAC_PI_4; - self.pos = ( + ( x + angle.cos() * CLICK_OFFSET, y + angle.sin() * CLICK_OFFSET, - ); - } - } else { - self.pos = (x, y); + ) + }; + self.pos = Some(position); } self.click_t = Some(0.0); if matches!( @@ -754,7 +740,7 @@ pub struct FocusRect { /// Render the cursor + bloom + click-pulse + (optional) focus-rect into a /// fresh tiny-skia [`tiny_skia::Pixmap`] of `(width, height)`. /// -/// `origin_x`, `origin_y` are subtracted from the cursor `core.pos` before +/// `origin_x`, `origin_y` are subtracted from the cursor position before /// drawing — Windows passes the virtual-screen `(virt_x, virt_y)` so the /// pixmap is laid out in window-local coordinates. macOS / Linux pass /// `(0.0, 0.0)`. @@ -785,9 +771,9 @@ pub fn render_frame( /// them with later calls drawn on top — this is what lets the macOS overlay /// render N owned cursors into one buffer / one NSWindow. /// -/// `origin_x` / `origin_y` are subtracted from `core.pos` before drawing +/// `origin_x` / `origin_y` are subtracted from the placed position before drawing /// (Windows passes the virtual-screen origin; macOS / Linux pass `(0.0, 0.0)`). -/// Both are in **logical** screen points, just like `core.pos`. +/// Both are in **logical** screen points, just like the cursor position. /// /// `backing_scale` is the destination-pixmap-pixels per logical-point ratio. /// On a 2× retina macOS display the caller sizes the pixmap at the screen's @@ -810,16 +796,19 @@ pub fn paint_cursor( focus_rect: Option, backing_scale: f32, ) { - if !core.visible || core.pos.0 < -100.0 || core.idle_alpha < 0.004 { + if !core.cursor_is_revealed() { return; } + let Some((cursor_x, cursor_y)) = core.pos else { + return; + }; let s = backing_scale.max(1.0) as f64; // logical-pt → pixmap-pixel scale let sf = s as f32; // Cursor anchor in pixmap-pixel space: subtract the (logical) origin // first, then scale into pixmap pixels. - let (px, py) = ((core.pos.0 - origin_x) * s, (core.pos.1 - origin_y) * s); + let (px, py) = ((cursor_x - origin_x) * s, (cursor_y - origin_y) * s); let heading = core.heading; let alpha_scale = core.idle_alpha as f32; @@ -925,6 +914,55 @@ pub fn paint_cursor( } } +#[cfg(test)] +mod placement_tests { + use super::*; + use crate::CursorConfig; + + #[test] + fn only_unplaced_cursors_are_unrevealed() { + let mut core = RenderStateCore::new(CursorConfig::default()); + assert_eq!(core.pos, None); + assert!(!core.cursor_is_revealed()); + + for position in [(-867.0, 400.0), (400.0, -867.0), (-200.0, -200.0)] { + core.pos = Some(position); + assert!(core.cursor_is_revealed(), "position={position:?}"); + } + } + + #[test] + fn macos_commands_preserve_a_placed_cursor_on_a_left_display() { + let mut core = RenderStateCore::new(CursorConfig::default()); + core.pos = Some((-867.0, 400.0)); + + assert!(core.apply_command_base( + OverlayCommand::MoveTo { + x: 40.0, + y: 60.0, + end_heading_radians: 0.0, + }, + false, + )); + assert_eq!(core.pos, Some((-867.0, 400.0))); + + assert!(core.apply_command_base(OverlayCommand::ClickPulse { x: 40.0, y: 60.0 }, false,)); + + assert_eq!(core.pos, Some((-867.0, 400.0))); + } + + #[test] + fn paint_cursor_renders_negative_global_x_with_a_virtual_screen_origin() { + let mut core = RenderStateCore::new(CursorConfig::default()); + core.pos = Some((-867.0, 120.0)); + let mut pixmap = tiny_skia::Pixmap::new(400, 300).unwrap(); + + paint_cursor(&mut pixmap, &core, -1000.0, 0.0, None, 1.0); + + assert!(pixmap.data().chunks_exact(4).any(|pixel| pixel[3] > 0)); + } +} + #[cfg(test)] mod glide_duration_tests { use super::*; @@ -937,7 +975,7 @@ mod glide_duration_tests { let mut core = RenderStateCore::new(CursorConfig::default()); core.motion.glide_duration_ms = glide_ms; core.motion.idle_hide_ms = 0.0; - core.pos = (0.0, 0.0); + core.pos = Some((0.0, 0.0)); // Aligned headings → an effectively straight path of length ~dist_pts. core.path = Some(PathPlanner::plan( 0.0, 0.0, 0.0, dist_pts, 0.0, 0.0, 0.0, 80.0, @@ -994,11 +1032,7 @@ mod session_badge_and_action_tests { fn session_badge_holds_then_fades_once() { let mut core = RenderStateCore::new(CursorConfig::default()); assert_eq!(core.session_badge_alpha(), 0.0); - assert!(core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - )); + assert!(core.apply_command_base(OverlayCommand::SetSessionLabel("Research".into()), true,)); assert_eq!(core.session_badge_alpha(), 1.0); core.tick_motion(SESSION_BADGE_HOLD_SECS - 0.05); @@ -1013,37 +1047,21 @@ mod session_badge_and_action_tests { #[test] fn repeated_session_label_metadata_does_not_restart_badge_timer() { let mut core = RenderStateCore::new(CursorConfig::default()); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - ); + core.apply_command_base(OverlayCommand::SetSessionLabel("Research".into()), true); core.tick_motion(SESSION_BADGE_HOLD_SECS + SESSION_BADGE_FADE_SECS); assert_eq!(core.session_badge_alpha(), 0.0); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - ); + core.apply_command_base(OverlayCommand::SetSessionLabel("Research".into()), true); assert_eq!(core.session_badge_alpha(), 0.0); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Writing".into()), - false, - false, - ); + core.apply_command_base(OverlayCommand::SetSessionLabel("Writing".into()), true); assert_eq!(core.session_badge_alpha(), 1.0); } #[test] fn revealing_hidden_cursor_restarts_badge_without_restarting_on_every_move() { let mut core = RenderStateCore::new(CursorConfig::default()); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - ); + core.apply_command_base(OverlayCommand::SetSessionLabel("Research".into()), true); core.tick_motion(SESSION_BADGE_HOLD_SECS + SESSION_BADGE_FADE_SECS); assert_eq!(core.session_badge_alpha(), 0.0); @@ -1053,8 +1071,7 @@ mod session_badge_and_action_tests { y: 100.0, heading_radians: None, }, - false, - false, + true, ); assert_eq!(core.session_badge_alpha(), 1.0); assert!(core.session_badge_needs_frame_tick()); @@ -1066,8 +1083,7 @@ mod session_badge_and_action_tests { y: 120.0, heading_radians: None, }, - false, - false, + true, ); assert_eq!(core.session_badge_secs, elapsed); core.tick_motion(SESSION_BADGE_HOLD_SECS + SESSION_BADGE_FADE_SECS); @@ -1077,12 +1093,8 @@ mod session_badge_and_action_tests { #[test] fn hardware_pointer_hover_reveals_only_while_over_cursor() { let mut core = RenderStateCore::new(CursorConfig::default()); - core.pos = (300.0, 240.0); - core.apply_command_base( - OverlayCommand::SetSessionLabel("Research".into()), - false, - false, - ); + core.pos = Some((300.0, 240.0)); + core.apply_command_base(OverlayCommand::SetSessionLabel("Research".into()), true); core.tick_motion(SESSION_BADGE_HOLD_SECS + SESSION_BADGE_FADE_SECS); assert_eq!(core.session_badge_alpha(), 0.0); assert!(core.session_badge_needs_hover_poll()); @@ -1099,15 +1111,14 @@ mod session_badge_and_action_tests { #[test] fn movement_preserves_the_active_semantic_action() { let mut core = RenderStateCore::new(CursorConfig::default()); - core.pos = (20.0, 20.0); + core.pos = Some((20.0, 20.0)); core.apply_command_base( OverlayCommand::BeginAction { action: CursorAction::Text, delivery: None, target: Some(TargetModifier::Ax), }, - false, - false, + true, ); core.apply_command_base( OverlayCommand::MoveTo { @@ -1115,16 +1126,11 @@ mod session_badge_and_action_tests { y: 100.0, end_heading_radians: 0.0, }, - false, - false, + true, ); assert_eq!(core.visual.resolved_action, CursorAction::Text); assert_eq!(core.visual.target, Some(TargetModifier::Ax)); - core.apply_command_base( - OverlayCommand::ClickPulse { x: 200.0, y: 100.0 }, - false, - false, - ); + core.apply_command_base(OverlayCommand::ClickPulse { x: 200.0, y: 100.0 }, true); assert_eq!(core.visual.resolved_action, CursorAction::Text); assert_eq!(core.visual.target, Some(TargetModifier::Ax)); } @@ -1132,15 +1138,14 @@ mod session_badge_and_action_tests { #[test] fn modifiers_live_in_the_badge_then_fade_after_action_completion() { let mut core = RenderStateCore::new(CursorConfig::default()); - core.pos = (200.0, 200.0); + core.pos = Some((200.0, 200.0)); core.apply_command_base( OverlayCommand::BeginAction { action: CursorAction::Click, delivery: Some(DeliveryModifier::Foreground), target: Some(TargetModifier::Pixel), }, - false, - false, + true, ); assert_eq!( core.badge_modifiers, @@ -1174,8 +1179,7 @@ mod session_badge_and_action_tests { delivery: Some(DeliveryModifier::Background), target: Some(TargetModifier::Ax), }, - false, - false, + true, ); core.apply_command_base( OverlayCommand::BeginAction { @@ -1183,8 +1187,7 @@ mod session_badge_and_action_tests { delivery: Some(DeliveryModifier::Foreground), target: Some(TargetModifier::Browser), }, - false, - false, + true, ); assert_eq!( core.badge_modifiers, @@ -1206,14 +1209,9 @@ mod session_badge_and_action_tests { delivery: Some(DeliveryModifier::Background), target: Some(TargetModifier::Ax), }, - false, - false, - ); - core.apply_command_base( - OverlayCommand::ClickPulse { x: 40.0, y: 60.0 }, - false, - false, + true, ); + core.apply_command_base(OverlayCommand::ClickPulse { x: 40.0, y: 60.0 }, true); assert_eq!( (core.visual.delivery, core.visual.target), (Some(DeliveryModifier::Background), Some(TargetModifier::Ax)) @@ -1262,7 +1260,7 @@ mod backing_scale_tests { // Place the cursor at the centre of the logical area and disable // idle-fade so the arrow paints at full alpha regardless of timing. let centre = logical_size as f64 / 2.0; - core.pos = (centre, centre); + core.pos = Some((centre, centre)); core.idle_alpha = 1.0; core.visible = true; diff --git a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs index 710aa22e01..b03e3f0793 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs @@ -399,30 +399,21 @@ pub fn is_visible_for_session(key: &str) -> bool { guard .as_ref() .and_then(|map| map.cursors.get(key)) - .map(|rs| { - rs.core.cfg.enabled - && rs.core.visible - && rs.core.idle_alpha >= 0.004 - && rs.core.pos.0 >= -100.0 - }) + .map(|rs| rs.core.cfg.enabled && rs.core.cursor_is_revealed()) }) .unwrap_or(false) } -pub fn current_position() -> (f64, f64) { +pub fn current_position() -> Option<(f64, f64)> { current_position_for("default") } -pub fn current_position_for(key: &str) -> (f64, f64) { - RENDER - .lock() - .ok() - .and_then(|g| { - g.as_ref() - .and_then(|m| m.cursors.get(key)) - .map(|rs| rs.core.pos) - }) - .unwrap_or((-200.0, -200.0)) +pub fn current_position_for(key: &str) -> Option<(f64, f64)> { + RENDER.lock().ok().and_then(|g| { + g.as_ref() + .and_then(|m| m.cursors.get(key)) + .and_then(|rs| rs.core.pos) + }) } pub fn current_motion_for(key: &str) -> cursor_overlay::MotionConfig { @@ -459,7 +450,7 @@ pub fn current_theme_state_for( Some((id, version, profile, fallback, state.core.visual.clone())) } -fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool { +fn seed_start_if_unplaced(key: &CursorKey, target_x: f64, target_y: f64) -> bool { const SEED_OFFSET: f64 = 140.0; let mut guard = RENDER.lock().unwrap(); let Some(map) = guard.as_mut() else { @@ -474,7 +465,7 @@ fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool .cursors .entry(key.clone()) .or_insert_with(|| render_state_for_key(&template, &k)); - if !(rs.core.cfg.enabled && rs.core.pos.0 < -50.0) { + if !(rs.core.cfg.enabled && rs.core.pos.is_none()) { return false; } let max_x = map.scr_w.max(2) as f64 - 2.0; @@ -485,7 +476,7 @@ fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool sx = (target_x + SEED_OFFSET).clamp(2.0, max_x); sy = (target_y + SEED_OFFSET).clamp(2.0, max_y); } - rs.core.pos = (sx, sy); + rs.core.pos = Some((sx, sy)); true } @@ -497,11 +488,11 @@ pub async fn animate_cursor_to_for(key: CursorKey, x: f64, y: f64) { if key.is_empty() { return; } - seed_start_if_sentinel(&key, x, y); + seed_start_if_unplaced(&key, x, y); let should_animate = { let guard = RENDER.lock().unwrap(); match guard.as_ref().and_then(|m| m.cursors.get(&key)) { - Some(rs) if rs.core.cfg.enabled && rs.core.visible && rs.core.pos.0 > -50.0 => true, + Some(rs) if rs.core.cfg.enabled && rs.core.visible && rs.core.pos.is_some() => true, _ => false, } }; @@ -620,17 +611,16 @@ impl RenderState { } fn apply_command(&mut self, cmd: OverlayCommand) { - // Linux uses the non-sentinel-snap behaviour for both MoveTo and - // ClickPulse: every command updates `self.pos` unconditionally. + // Linux click pulses update the cursor position unconditionally. // Custom-shape / gradient / focus-rect commands are not rendered on // Linux at present; `apply_command_base` consumes SetShape + // SetGradient and returns false for ShowFocusRect — both cases drop // the visual update silently so callers don't see an error. - let _ = self.core.apply_command_base(cmd, false, false); + let _ = self.core.apply_command_base(cmd, true); } /// True while the render loop must wake at frame cadence because the next - /// tick can change pixels. A brand-new sentinel cursor is deliberately + /// tick can change pixels. A brand-new unplaced cursor is deliberately /// quiescent, so an idle MCP server can park on bounded maintenance waits /// instead of rebuilding and repainting X11 cursor tiles at 60 fps. #[cfg(target_os = "linux")] @@ -647,13 +637,10 @@ impl RenderState { // mid-swing on Linux while macOS keeps levitating. The term dies // with `idle_alpha` once the idle fade completes, returning the // parked-overlay fast path to the fully hidden cursor. - || (self.core.visible - && self.core.pos.0 >= -100.0 - && self.core.idle_alpha >= 0.004 + || (self.core.cursor_is_revealed() && self.core.visual.reduced_motion != cursor_overlay::ReducedMotion::On) || (self.core.motion.idle_hide_ms > 0.0 - && self.core.visible - && self.core.pos.0 >= -100.0 + && self.core.cursor_is_revealed() && self.core.idle_secs >= fade_start && self.core.idle_alpha >= 0.004) } @@ -666,9 +653,7 @@ fn render_map_needs_frame_tick(map: &RenderMap) -> bool { #[cfg(target_os = "linux")] fn render_map_needs_z_order_tick(map: &RenderMap) -> bool { - map.cursors - .values() - .any(|rs| rs.core.visible && rs.core.idle_alpha >= 0.004 && rs.core.pos.0 >= -100.0) + map.cursors.values().any(|rs| rs.core.cursor_is_revealed()) } #[cfg(target_os = "linux")] @@ -740,8 +725,7 @@ fn render_map_idle_wait_interval(map: &RenderMap) -> Option { .values() .filter_map(|rs| { let core = &rs.core; - if !core.visible - || core.pos.0 < -100.0 + if !core.cursor_is_revealed() || core.motion.idle_hide_ms <= 0.0 || core.path.is_some() || core.spring.is_some() @@ -2066,9 +2050,10 @@ fn cursor_tile_bounds( screen_width: u32, screen_height: u32, ) -> Option { - if !core.visible || core.pos.0 < -100.0 || core.idle_alpha < 0.004 { + if !core.cursor_is_revealed() { return None; } + let (cursor_x, cursor_y) = core.pos?; let screen_width = i32::try_from(screen_width).ok()?; let screen_height = i32::try_from(screen_height).ok()?; @@ -2077,10 +2062,10 @@ fn cursor_tile_bounds( } else { X11_CURSOR_TILE_MARGIN }; - let left = (core.pos.0 - horizontal_margin).floor() as i32; - let top = (core.pos.1 - X11_CURSOR_TILE_MARGIN).floor() as i32; - let right = (core.pos.0 + horizontal_margin).ceil() as i32; - let bottom = (core.pos.1 + X11_CURSOR_TILE_MARGIN).ceil() as i32; + let left = (cursor_x - horizontal_margin).floor() as i32; + let top = (cursor_y - X11_CURSOR_TILE_MARGIN).floor() as i32; + let right = (cursor_x + horizontal_margin).ceil() as i32; + let bottom = (cursor_y + X11_CURSOR_TILE_MARGIN).ceil() as i32; let left = left.clamp(0, screen_width); let top = top.clamp(0, screen_height); @@ -2843,22 +2828,19 @@ mod tests { fn wait_for_cursor_move_from(x: f64, y: f64, phase: &str) -> anyhow::Result<()> { let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { - let position = current_position(); - if (position.0 - x).hypot(position.1 - y) > 32.0 { - eprintln!( - "{phase}: cursor moved from ({x}, {y}) to ({}, {})", - position.0, position.1 - ); - return Ok(()); + if let Some(position) = current_position() { + if (position.0 - x).hypot(position.1 - y) > 32.0 { + eprintln!( + "{phase}: cursor moved from ({x}, {y}) to ({}, {})", + position.0, position.1 + ); + return Ok(()); + } } std::thread::sleep(Duration::from_millis(10)); } let position = current_position(); - anyhow::bail!( - "cursor position remained ({}, {}) near ({x}, {y}) during {phase}", - position.0, - position.1 - ) + anyhow::bail!("cursor position remained {position:?} near ({x}, {y}) during {phase}") } let (conn, screen_num) = x11rb::connect(None)?; @@ -3279,7 +3261,7 @@ mod tests { fn maintenance_tick_advances_the_full_elapsed_interval() { let mut map = default_render_map(); let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (10.0, 10.0); + cursor.core.pos = Some((10.0, 10.0)); cursor.core.motion.idle_hide_ms = 500.0; let (_tx, rx) = std::sync::mpsc::channel(); @@ -3458,7 +3440,7 @@ mod tests { let mut map = default_render_map(); map.scr_w = 1920; map.scr_h = 2160; - map.cursors.get_mut("default").unwrap().core.pos = (100.0, 2000.0); + map.cursors.get_mut("default").unwrap().core.pos = Some((100.0, 2000.0)); assert_eq!(render_x11_tiles(&map).len(), 1); update_render_map_geometry(&mut map, 1920, 1080); @@ -3466,7 +3448,7 @@ mod tests { } #[test] - fn sentinel_default_cursor_does_not_require_frame_ticks() { + fn unplaced_default_cursor_does_not_require_frame_ticks() { let map = default_render_map(); assert!(!render_map_needs_frame_tick(&map)); assert!(!render_map_needs_z_order_tick(&map)); @@ -3479,7 +3461,7 @@ mod tests { fn resting_visible_cursor_only_requires_cheap_z_order_ticks() { let mut map = default_render_map(); let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (100.0, 100.0); + cursor.core.pos = Some((100.0, 100.0)); cursor.core.motion.idle_hide_ms = 0.0; cursor.core.visual.reduced_motion = cursor_overlay::ReducedMotion::On; @@ -3491,7 +3473,7 @@ mod tests { fn resting_visible_cursor_keeps_ticking_for_the_float_bob() { let mut map = default_render_map(); let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (100.0, 100.0); + cursor.core.pos = Some((100.0, 100.0)); cursor.core.motion.idle_hide_ms = 0.0; // Default reduced_motion (auto) floats, so frames keep flowing while @@ -3508,7 +3490,7 @@ mod tests { fn disabling_settled_cursor_clears_once_then_parks() { let mut map = default_render_map(); let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (100.0, 100.0); + cursor.core.pos = Some((100.0, 100.0)); cursor.core.motion.idle_hide_ms = 0.0; cursor.core.visual.reduced_motion = cursor_overlay::ReducedMotion::On; let (_tx, rx) = std::sync::mpsc::channel(); @@ -3555,7 +3537,7 @@ mod tests { let cursor = map.cursors.get_mut("default").unwrap(); // The public animate path seeds a newly created cursor near its target // before sending MoveTo; mirror that valid on-screen starting state. - cursor.core.pos = (100.0, 100.0); + cursor.core.pos = Some((100.0, 100.0)); cursor.core.motion.idle_hide_ms = 500.0; cursor.core.visual.reduced_motion = cursor_overlay::ReducedMotion::On; cursor.apply_command(OverlayCommand::MoveTo { @@ -3591,7 +3573,7 @@ mod tests { let mut map = default_render_map(); { let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (10.0, 10.0); + cursor.core.pos = Some((10.0, 10.0)); cursor.core.motion.idle_hide_ms = 500.0; } let other = render_state_for_key(&map.template, "other"); @@ -3631,11 +3613,11 @@ mod tests { let mut map = default_render_map(); { let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (10.0, 10.0); + cursor.core.pos = Some((10.0, 10.0)); cursor.core.motion.idle_hide_ms = 500.0; } let mut other = render_state_for_key(&map.template, "other"); - other.core.pos = (20.0, 20.0); + other.core.pos = Some((20.0, 20.0)); map.cursors.insert("other".to_owned(), other); // Model a command arriving after recv_timeout returned Timeout but @@ -3658,7 +3640,7 @@ mod tests { assert_eq!(map.cursors["default"].core.idle_secs, 0.08); let other = &map.cursors["other"].core; assert!(other.path.is_some()); - assert_eq!(other.pos, (20.0, 20.0)); + assert_eq!(other.pos, Some((20.0, 20.0))); assert_eq!(other.dist, 0.0); } @@ -3666,7 +3648,7 @@ mod tests { fn click_pulse_drained_after_maintenance_timeout_starts_at_zero_dt() { let mut map = default_render_map(); let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (20.0, 20.0); + cursor.core.pos = Some((20.0, 20.0)); let (tx, rx) = std::sync::mpsc::channel(); tx.send(OverlayMsg::Cmd(KeyedOverlayCommand { @@ -3680,7 +3662,7 @@ mod tests { assert!(arrived.is_empty()); assert!(had_msg); let cursor = &map.cursors["default"].core; - assert_eq!(cursor.pos, (40.0, 50.0)); + assert_eq!(cursor.pos, Some((40.0, 50.0))); assert_eq!(cursor.click_t, Some(0.0)); } @@ -3689,7 +3671,7 @@ mod tests { let mut map = default_render_map(); { let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (20.0, 20.0); + cursor.core.pos = Some((20.0, 20.0)); cursor.apply_command(OverlayCommand::MoveTo { x: 80.0, y: 80.0, @@ -3727,7 +3709,7 @@ mod tests { let mut map = default_render_map(); { let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (100.0, 100.0); + cursor.core.pos = Some((100.0, 100.0)); cursor.core.motion.idle_hide_ms = 500.0; cursor.core.visual.reduced_motion = cursor_overlay::ReducedMotion::On; @@ -3793,7 +3775,7 @@ mod tests { map.scr_w = 7680; map.scr_h = 2160; let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (4000.0, 1000.0); + cursor.core.pos = Some((4000.0, 1000.0)); let tiles = render_x11_tiles(&map); @@ -3811,7 +3793,7 @@ mod tests { map.scr_w = 7680; map.scr_h = 2160; let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (4000.0, 1000.0); + cursor.core.pos = Some((4000.0, 1000.0)); cursor.apply_command(OverlayCommand::SetSessionLabel("research-run".to_owned())); let tiles = render_x11_tiles(&map); @@ -3828,7 +3810,7 @@ mod tests { map.scr_w = 7680; map.scr_h = 2160; let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (4000.0, 1000.0); + cursor.core.pos = Some((4000.0, 1000.0)); cursor.apply_command(OverlayCommand::BeginAction { action: CursorAction::Click, delivery: Some(cursor_overlay::DeliveryModifier::Foreground), @@ -3848,7 +3830,7 @@ mod tests { map.scr_w = 1920; map.scr_h = 1080; let cursor = map.cursors.get_mut("default").unwrap(); - cursor.core.pos = (10.0, 12.0); + cursor.core.pos = Some((10.0, 12.0)); let tiles = render_x11_tiles(&map); assert_eq!(tiles.len(), 1); @@ -3871,9 +3853,9 @@ mod tests { let mut map = default_render_map(); map.scr_w = 7680; map.scr_h = 2160; - map.cursors.get_mut("default").unwrap().core.pos = (100.0, 100.0); + map.cursors.get_mut("default").unwrap().core.pos = Some((100.0, 100.0)); let mut other = render_state_for_key(&map.template, "other"); - other.core.pos = (7400.0, 1800.0); + other.core.pos = Some((7400.0, 1800.0)); map.cursors.insert("other".to_owned(), other); let tiles = render_x11_tiles(&map); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e71a9c8f34..12f6e1b932 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -2056,7 +2056,7 @@ async fn overlay_glide_to_for(cursor_id: &str, sx: f64, sy: f64) { crate::wayland::shell_helper::move_cursor(sx as i32, sy as i32); } let pos = crate::overlay::current_position_for(cursor_id); - if pos.0 < 0.0 && pos.1 < 0.0 { + if pos.is_none() { crate::overlay::send_command_for( cursor_id.to_owned(), cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs index 088846188e..cb5a3c5fc9 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs @@ -314,13 +314,8 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { break; } Ok(WlOverlayCmd::Cmd { cmd }) => { - // Seed: if the cursor is still at the off-screen sentinel - // `(-200, -200)` from `RenderStateCore::new`, snap to a - // point near the MoveTo / SnapTo target so the spring - // animation starts on-screen. Mirrors X11 overlay.rs's - // `seed_start_if_sentinel` helper — without it, the - // spring oscillates around the sentinel and the cursor - // never reaches the screen. + // Seed an unplaced cursor near the target so its first + // spring animation starts on-screen, matching X11. let seed_target = match &cmd { OverlayCommand::MoveTo { x, y, .. } | OverlayCommand::SnapTo { x, y, .. } @@ -328,18 +323,16 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { _ => None, }; if let Some((tx, ty)) = seed_target { - if state.core.pos.0 < -50.0 { + if state.core.pos.is_none() { const SEED_OFFSET: f64 = 16.0; let sx = (tx - SEED_OFFSET).max(2.0); let sy = (ty - SEED_OFFSET).max(2.0); - state.core.pos = (sx, sy); + state.core.pos = Some((sx, sy)); } } - // apply_command_base consumes every variant the X11 - // path handles. `move_to_snap_sentinel` / `click_pulse - // _sentinel_only` are both `false` here — same as X11. + // apply_command_base consumes every variant X11 handles. let disabling = matches!(&cmd, OverlayCommand::SetEnabled(false)); - dirty |= state.core.apply_command_base(cmd, false, false); + dirty |= state.core.apply_command_base(cmd, true); if disabling { quiesce_hidden(&mut state.core); } @@ -348,7 +341,7 @@ fn owner_thread(rx: Receiver) -> anyhow::Result<()> { // Single-cursor overlay: removing the active cursor // hides it. Multi-cursor wlroots support can layer on // top of this in a follow-up if needed. - dirty |= state.core.visible || state.core.pos.0 >= -100.0; + dirty |= state.core.visible || state.core.pos.is_some(); state.core.visible = false; quiesce_hidden(&mut state.core); } @@ -438,7 +431,7 @@ fn next_wait(core: &RenderStateCore, frame_tick_needed: bool) -> WlWait { } fn needs_frame_tick(core: &RenderStateCore) -> bool { - if !core.visible || core.pos.0 < -100.0 { + if !core.cursor_is_revealed() { return false; } let fade_start = core.motion.idle_hide_ms / 1000.0; @@ -453,7 +446,7 @@ fn needs_frame_tick(core: &RenderStateCore) -> bool { fn idle_fade_wait(core: &RenderStateCore) -> Option { if !core.visible - || core.pos.0 < -100.0 + || core.pos.is_none() || core.motion.idle_hide_ms <= 0.0 || core.path.is_some() || core.spring.is_some() @@ -486,7 +479,7 @@ fn quiesce_hidden(core: &mut RenderStateCore) { /// 4. Attach + damage + commit on the layer surface. /// /// When the cursor is hidden (`core.visible == false`, idle-faded, or -/// off-screen sentinel) the pixmap is all zeros — the surface remains +/// unplaced) the pixmap is all zeros — the surface remains /// transparent and click-through. fn redraw( state: &mut OverlayState, @@ -535,8 +528,7 @@ fn redraw( // layer-shell visibility on a new compositor — the gradient arrow is // small at native scale and easy to miss in a screenshot, while the // magenta block is impossible to miss. - if std::env::var_os("CUA_OVERLAY_DEBUG").is_some() { - let (cx, cy) = state.core.pos; + if let (Some((cx, cy)), Some(_)) = (state.core.pos, std::env::var_os("CUA_OVERLAY_DEBUG")) { let cx = cx as i32; let cy = cy as i32; let half = 30i32; @@ -588,8 +580,8 @@ fn redraw( state.pending_buffers.insert(buffer_id, (ptr, size, fd)); dbg(&format!( - "redraw w={w} h={h} stride={stride} buf_id={buffer_id} pos=({:.1},{:.1}) visible={}", - state.core.pos.0, state.core.pos.1, state.core.visible + "redraw w={w} h={h} stride={stride} buf_id={buffer_id} pos={:?} visible={}", + state.core.pos, state.core.visible )); surface.attach(Some(&buffer), 0, 0); surface.damage_buffer(0, 0, w as i32, h as i32); @@ -793,13 +785,13 @@ mod tests { fn positioned_core() -> RenderStateCore { let mut core = RenderStateCore::new(CursorConfig::default()); - core.pos = (100.0, 100.0); + core.pos = Some((100.0, 100.0)); core.motion.idle_hide_ms = 1_000.0; core } #[test] - fn fresh_sentinel_overlay_blocks_without_frame_polling() { + fn fresh_unplaced_overlay_blocks_without_frame_polling() { let core = RenderStateCore::new(CursorConfig::default()); assert_eq!(next_wait(&core, false), WlWait::Block); assert!(!needs_frame_tick(&core)); diff --git a/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs b/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs index a99fe59823..bed0e5bbd8 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/cursor/overlay.rs @@ -25,7 +25,7 @@ //! Animation state + render pipeline live in `cursor_overlay::render_state` //! (`RenderStateCore`, `tick_swift_constants`, `apply_command_base`, //! `render_frame`). macOS uses the hardcoded Swift reference constants -//! (peakSpeed=900, springK=400, overshoot=0.8) and the sentinel-snap +//! (peakSpeed=900, springK=400, overshoot=0.8) and first-placement //! variants of MoveTo / ClickPulse — see the wrapper around //! `apply_command_base` below. @@ -313,19 +313,19 @@ pub fn current_theme_state( Some((id, version, profile, fallback, state.core.visual.clone())) } -/// Seed a brand-new (sentinel-positioned) cursor at an on-screen start point +/// Seed a brand-new cursor at an on-screen start point /// offset up-left of `(target_x, target_y)` so the immediately-following /// `MoveTo` glides INTO the target instead of silently snapping. Without this, /// a cursor's very first action (common on a pure-AX run — launch app, AX-press /// a button) produces no visible motion: `animate_cursor_to` early-returned at -/// the sentinel and only `ClickPulse` snapped a static arrow, which is easy to +/// no position and only `ClickPulse` placed a static arrow, which is easy to /// miss. See the AX-no-glide report. /// -/// No-op when the cursor is already on-screen (pos.0 > -50.0) or absent. The +/// No-op when the cursor is already placed or absent. The /// seed is clamped to the main screen frame so it never starts off-display. -/// Returns true if a seed was applied (i.e. the cursor was at the sentinel and +/// Returns true if a seed was applied (i.e. the cursor was unplaced and /// is now primed to glide). -fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool { +fn seed_start_if_unplaced(key: &CursorKey, target_x: f64, target_y: f64) -> bool { let mut guard = RENDER.lock().unwrap(); let Some(map) = guard.as_mut() else { return false; @@ -334,7 +334,7 @@ fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool } /// Pure seed step operating on a borrowed [`RenderMap`] — factored out of -/// `seed_start_if_sentinel` so the get-or-create + clamp logic is unit-testable +/// `seed_start_if_unplaced` so the get-or-create + clamp logic is unit-testable /// without the global `RENDER` static or AppKit. fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target_y: f64) -> bool { // Offset the start up-left of the target so the Dubins path has room to @@ -356,7 +356,7 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target .cursors .entry(key.clone()) .or_insert_with(|| render_state_for_key(&template, &k)); - if !(rs.core.cfg.enabled && rs.core.pos.0 < -50.0) { + if !(rs.core.cfg.enabled && rs.core.pos.is_none()) { return false; } let mut sx = target_x - SEED_OFFSET; @@ -374,7 +374,7 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target sy = (target_y + SEED_OFFSET).min(win_h - 2.0); } } - rs.core.pos = (sx, sy); + rs.core.pos = Some((sx, sy)); true } @@ -383,8 +383,8 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target /// /// Mirrors Swift's `AgentCursor.shared.animateAndWait(to:)`. /// Returns immediately (no animation) only when the overlay is disabled for -/// this cursor. A brand-new cursor still at the off-screen sentinel is first -/// seeded on-screen via [`seed_start_if_sentinel`] so its FIRST action glides +/// this cursor. A brand-new cursor is first seeded on-screen via +/// [`seed_start_if_unplaced`] so its first action glides /// in (it previously snapped silently via `ClickPulse`, invisible on a pure-AX /// run). pub async fn animate_cursor_to(key: CursorKey, x: f64, y: f64) { @@ -392,10 +392,10 @@ pub async fn animate_cursor_to(key: CursorKey, x: f64, y: f64) { if key.is_empty() { return; } - // Seed a sentinel cursor on-screen so the MoveTo below glides instead of - // being short-circuited. After this the cursor's pos.0 > -50.0, so the - // should-animate check passes on the first action just like later ones. - seed_start_if_sentinel(&key, x, y); + // Seed an unplaced cursor on-screen so the MoveTo below glides instead of + // being short-circuited. After this the cursor has a position, so + // the should-animate check passes on the first action just like later ones. + seed_start_if_unplaced(&key, x, y); // Check whether animation should run for THIS cursor. A disabled cursor // never animates; an absent cursor (seed found nothing to prime) is skipped. @@ -403,7 +403,7 @@ pub async fn animate_cursor_to(key: CursorKey, x: f64, y: f64) { let guard = RENDER.lock().unwrap(); matches!( guard.as_ref().and_then(|m| m.cursors.get(&key)), - Some(rs) if rs.core.cfg.enabled && rs.core.pos.0 > -50.0 + Some(rs) if rs.core.cfg.enabled && rs.core.pos.is_some() ) }; if !should_animate { @@ -529,25 +529,21 @@ impl RenderState { } fn apply_command(&mut self, cmd: OverlayCommand) { - // macOS uses the sentinel-snap variants of MoveTo / ClickPulse: - // - MoveTo only snaps `self.pos` if the cursor is still at the - // off-screen sentinel `(-200, -200)` (otherwise the path starts - // from the current position so the animation is continuous). - // - ClickPulse only updates `self.pos` if the cursor is still at - // the sentinel (otherwise the animation already landed it there). + // A macOS click pulse preserves the position reached by its glide. + // The shared core places an unplaced cursor on first use. match cmd { OverlayCommand::ShowFocusRect(rect) => { self.focus_rect = rect; self.focus_rect_t = 0.0; // reset fade to fully visible } other => { - let _ = self.core.apply_command_base(other, true, true); + let _ = self.core.apply_command_base(other, false); } } } /// True while the render loop must wake at frame cadence because the next - /// tick can change pixels. A brand-new sentinel cursor is deliberately + /// tick can change pixels. A brand-new unplaced cursor is deliberately /// quiescent, so `serve` with no agent activity can block on the command /// channel instead of compositing an empty fullscreen pixmap at 60fps. fn needs_frame_tick(&self) -> bool { @@ -556,10 +552,7 @@ impl RenderState { || self.core.click_t.is_some() || self.focus_rect.is_some() || self.core.session_badge_needs_frame_tick() - || (self.core.motion.idle_hide_ms > 0.0 - && self.core.visible - && self.core.pos.0 >= -100.0 - && self.core.idle_alpha >= 0.004) + || (self.core.motion.idle_hide_ms > 0.0 && self.core.cursor_is_revealed()) } } @@ -928,11 +921,7 @@ fn hardware_cursor_position() -> Option<(f64, f64)> { } fn cursor_is_externally_visible(state: &RenderState) -> bool { - state.core.cfg.enabled - && state.core.visible - && state.core.pos.0 > -50.0 - && state.core.pos.1 > -50.0 - && state.core.idle_alpha >= 0.004 + state.core.cfg.enabled && state.core.cursor_is_revealed() } /// Convert a `tiny_skia::Pixmap` to a `CGImage` and set it as the contents @@ -1448,15 +1437,15 @@ mod tests { } #[test] - fn seed_moves_sentinel_cursor_on_screen_for_first_action() { - // BUG 2 regression: a brand-new session cursor at the sentinel must be + fn seed_places_cursor_on_screen_for_first_action() { + // BUG 2 regression: a brand-new session cursor without a position must be // seeded on-screen (pos.0 > -50) so the immediately-following MoveTo // glides instead of silently snapping via ClickPulse. let mut map = empty_map(); // 100x100 frame // No "sessA" cursor exists yet — the seed must get-or-create it. let seeded = seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); - assert!(seeded, "sentinel cursor must be seeded"); - let pos = map.cursors["sessA"].core.pos; + assert!(seeded, "unplaced cursor must be seeded"); + let pos = map.cursors["sessA"].core.pos.expect("seeded position"); assert!( pos.0 > -50.0 && pos.1 > -50.0, "seed must be on-screen, got {pos:?}" @@ -1475,16 +1464,32 @@ mod tests { let mut map = empty_map(); // Put sessA on-screen first. seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); - map.cursors.get_mut("sessA").unwrap().core.pos = (30.0, 30.0); + map.cursors.get_mut("sessA").unwrap().core.pos = Some((30.0, 30.0)); let seeded_again = seed_start_in_map(&mut map, &"sessA".to_owned(), 80.0, 80.0); assert!(!seeded_again, "on-screen cursor must not be re-seeded"); assert_eq!( map.cursors["sessA"].core.pos, - (30.0, 30.0), + Some((30.0, 30.0)), "pos must be untouched" ); } + #[test] + fn negative_display_coordinates_are_visible_and_not_reseeded() { + let mut map = empty_map(); + seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); + map.cursors.get_mut("sessA").unwrap().core.pos = Some((-867.0, 400.0)); + + assert!(!seed_start_in_map( + &mut map, + &"sessA".to_owned(), + 80.0, + 80.0 + )); + assert_eq!(map.cursors["sessA"].core.pos, Some((-867.0, 400.0))); + assert!(cursor_is_externally_visible(&map.cursors["sessA"])); + } + #[test] fn seed_does_not_resurrect_ended_session() { // The seed shares the resurrection guard: it must not re-create a cursor @@ -1500,7 +1505,7 @@ mod tests { } #[test] - fn sentinel_default_cursor_does_not_require_frame_ticks() { + fn unplaced_default_cursor_does_not_require_frame_ticks() { // Regression for idle CPU: a freshly-started serve daemon seeds only the // off-screen default cursor. With no commands in flight, the render loop // should be able to block on rx.recv() instead of repainting at 60fps. diff --git a/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs b/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs index 64917854a9..19c66af0b9 100644 --- a/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs +++ b/libs/cua-driver/rust/crates/platform-macos/src/tools/move_cursor.rs @@ -96,10 +96,8 @@ impl Tool for MoveCursorTool { self.state.cursor_registry.update_position(&cursor_id, x, y); // Drive the DRAWN cursor via the same path as click's animation. A raw - // `MoveTo` doesn't reliably bring a brand-new session cursor on-screen — - // it sits at the off-screen sentinel until a click seeds it, so the - // visible cursor wouldn't move (the reported position would, but the - // overlay wouldn't). `animate_cursor_to` seeds the sentinel on-screen + // `MoveTo` doesn't reliably bring a brand-new session cursor on-screen. + // `animate_cursor_to` gives an unplaced cursor a visible start point, // then glides in, identical to `click`. No-op for an empty (anonymous) // key or when the overlay is disabled for this cursor. crate::cursor::overlay::animate_cursor_to(cursor_id.clone(), x, y).await; diff --git a/libs/cua-driver/rust/crates/platform-windows/src/overlay.rs b/libs/cua-driver/rust/crates/platform-windows/src/overlay.rs index d9ecc74bfa..37563311b2 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/overlay.rs @@ -86,7 +86,7 @@ fn arrival_fire(key: &CursorKey) { struct RenderMap { cursors: IndexMap, /// Virtual screen dimensions set after window creation (Win32 DIPs). - /// `virt_x/y` are subtracted from each cursor's `core.pos` when rendering + /// `virt_x/y` are subtracted from each placed cursor when rendering /// so the pixmap is laid out in window-local coordinates. virt_x: i32, virt_y: i32, @@ -389,27 +389,21 @@ pub fn current_theme_state( Some((id, version, profile, fallback, state.core.visual.clone())) } -/// Current screen position of the cursor for `key` (the off-screen sentinel -/// `(-200, -200)` if it has never been placed). A session with no own cursor -/// yet reports the sentinel so the click path treats it as first-placement. -pub fn current_position(key: &str) -> (f64, f64) { - RENDER - .lock() - .ok() - .and_then(|g| { - g.as_ref() - .and_then(|m| m.cursors.get(key)) - .map(|rs| rs.core.pos) - }) - .unwrap_or((-200.0, -200.0)) +/// Current screen position of the cursor for `key`, if it has been placed. +pub fn current_position(key: &str) -> Option<(f64, f64)> { + RENDER.lock().ok().and_then(|g| { + g.as_ref() + .and_then(|m| m.cursors.get(key)) + .and_then(|rs| rs.core.pos) + }) } -/// Seed a brand-new (sentinel-positioned) cursor at an on-screen start point +/// Seed a brand-new cursor at an on-screen start point /// offset up-left of `(target_x, target_y)` so the immediately-following /// `MoveTo` glides INTO the target instead of silently snapping. No-op when the /// cursor is already on-screen or its session already ended. Returns true if a /// seed was applied. Mirrors `platform_macos::cursor::overlay::seed_start_*`. -fn seed_start_if_sentinel(key: &CursorKey, target_x: f64, target_y: f64) -> bool { +fn seed_start_if_unplaced(key: &CursorKey, target_x: f64, target_y: f64) -> bool { let mut guard = RENDER.lock().unwrap(); let Some(map) = guard.as_mut() else { return false; @@ -435,7 +429,7 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target .cursors .entry(key.clone()) .or_insert_with(|| render_state_for_key(&template, &k)); - if !(rs.core.cfg.enabled && rs.core.pos.0 < -50.0) { + if !(rs.core.cfg.enabled && rs.core.pos.is_none()) { return false; } let mut sx = target_x - SEED_OFFSET; @@ -451,7 +445,7 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target sy = (target_y + SEED_OFFSET).min(virt_y + virt_h - 2.0); } } - rs.core.pos = (sx, sy); + rs.core.pos = Some((sx, sy)); true } @@ -463,21 +457,21 @@ fn seed_start_in_map(map: &mut RenderMap, key: &CursorKey, target_x: f64, target /// - the key is empty (anonymous run → no cursor), or /// - the cursor for `key` is disabled. /// -/// A brand-new cursor still at the off-screen sentinel is first seeded -/// on-screen via [`seed_start_if_sentinel`] so its FIRST action glides in. +/// A brand-new cursor is first seeded on-screen via [`seed_start_if_unplaced`] +/// so its first action glides in. /// Mirrors `platform_macos::cursor::overlay::animate_cursor_to`. pub async fn animate_cursor_to(key: CursorKey, x: f64, y: f64) { if key.is_empty() { return; } - // Seed a sentinel cursor on-screen so the MoveTo below glides instead of + // Seed an unplaced cursor on-screen so the MoveTo below glides instead of // being short-circuited. - seed_start_if_sentinel(&key, x, y); + seed_start_if_unplaced(&key, x, y); let should_animate = { let guard = RENDER.lock().unwrap(); match guard.as_ref().and_then(|m| m.cursors.get(&key)) { - Some(rs) if rs.core.cfg.enabled && rs.core.pos.0 > -50.0 => true, + Some(rs) if rs.core.cfg.enabled && rs.core.pos.is_some() => true, _ => false, } }; @@ -559,18 +553,16 @@ impl RenderState { } fn apply_command(&mut self, cmd: OverlayCommand) { - // Windows uses the non-sentinel-snap behaviour for both MoveTo and - // ClickPulse: every command updates `self.pos` unconditionally. + // Windows click pulses update the cursor position unconditionally. // `ShowFocusRect` is not rendered on Windows — `apply_command_base` // returns `false` for it and we silently drop it here. - let _ = self.core.apply_command_base(cmd, false, false); + let _ = self.core.apply_command_base(cmd, true); } /// True while the render loop must keep ticking at frame cadence because /// the next tick can still change pixels: an in-flight glide path, a /// spring-settle, a click pulse, or an idle-fade actively fading - /// (`0.004 <= idle_alpha < 1.0`). A brand-new sentinel cursor (off-screen - /// at `(-200, -200)`), a cursor that has already faded to + /// (`0.004 <= idle_alpha < 1.0`). A brand-new unplaced cursor, one that has already faded to /// `idle_alpha ≈ 0`, AND a cursor resting at constant `idle_alpha == 1.0` /// waiting out the idle-hide countdown are all non-rendering states: the /// countdown phase leaves every frame pixel-identical, so compositing a @@ -585,9 +577,7 @@ impl RenderState { || self.core.click_t.is_some() || self.core.session_badge_needs_frame_tick() || (self.core.motion.idle_hide_ms > 0.0 - && self.core.visible - && self.core.pos.0 >= -100.0 - && self.core.idle_alpha >= 0.004 + && self.core.cursor_is_revealed() && self.core.idle_alpha < 1.0) } @@ -602,8 +592,7 @@ impl RenderState { && self.core.spring.is_none() && self.core.click_t.is_none() && self.core.motion.idle_hide_ms > 0.0 - && self.core.visible - && self.core.pos.0 >= -100.0 + && self.core.cursor_is_revealed() && self.core.idle_alpha >= 1.0 } } @@ -986,11 +975,14 @@ fn composite_dirty(map: &RenderMap) -> Option { // (mirrors paint_cursor's own visibility early-return). let mut current: Option = None; for rs in map.cursors.values() { - if !rs.core.visible || rs.core.pos.0 < -100.0 || rs.core.idle_alpha < 0.004 { + if !rs.core.cursor_is_revealed() { continue; } - let cx = (rs.core.pos.0 - map.virt_x as f64).round() as i32; - let cy = (rs.core.pos.1 - map.virt_y as f64).round() as i32; + let Some((cursor_x, cursor_y)) = rs.core.pos else { + continue; + }; + let cx = (cursor_x - map.virt_x as f64).round() as i32; + let cy = (cursor_y - map.virt_y as f64).round() as i32; let custom_theme = rs .core .theme @@ -1539,7 +1531,7 @@ impl ZOrderEnforcer for WinZOrderEnforcer { // // These prove the per-session ownership data model, the session_end removal // lifecycle, the "default" guard, the resurrection tombstone, and the -// sentinel seed WITHOUT any Win32 window. The on-screen rendering +// first-placement seed WITHOUT any Win32 window. The on-screen rendering // (UpdateLayeredWindow) still needs a real display and is verified separately. #[cfg(test)] @@ -1780,12 +1772,12 @@ mod tests { } #[test] - fn seed_moves_sentinel_cursor_on_screen_for_first_action() { + fn seed_places_cursor_on_screen_for_first_action() { let mut map = empty_map(); // 100x100 frame at origin // No "sessA" cursor exists yet — the seed must get-or-create it. let seeded = seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); - assert!(seeded, "sentinel cursor must be seeded"); - let pos = map.cursors["sessA"].core.pos; + assert!(seeded, "unplaced cursor must be seeded"); + let pos = map.cursors["sessA"].core.pos.expect("seeded position"); assert!( pos.0 > -50.0 && pos.1 > -50.0, "seed must be on-screen, got {pos:?}" @@ -1800,16 +1792,31 @@ mod tests { fn seed_is_noop_when_cursor_already_on_screen() { let mut map = empty_map(); seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); - map.cursors.get_mut("sessA").unwrap().core.pos = (30.0, 30.0); + map.cursors.get_mut("sessA").unwrap().core.pos = Some((30.0, 30.0)); let seeded_again = seed_start_in_map(&mut map, &"sessA".to_owned(), 80.0, 80.0); assert!(!seeded_again, "on-screen cursor must not be re-seeded"); assert_eq!( map.cursors["sessA"].core.pos, - (30.0, 30.0), + Some((30.0, 30.0)), "pos must be untouched" ); } + #[test] + fn negative_virtual_screen_coordinates_are_not_reseeded() { + let mut map = empty_map(); + seed_start_in_map(&mut map, &"sessA".to_owned(), 60.0, 60.0); + map.cursors.get_mut("sessA").unwrap().core.pos = Some((-867.0, 400.0)); + + assert!(!seed_start_in_map( + &mut map, + &"sessA".to_owned(), + 80.0, + 80.0 + )); + assert_eq!(map.cursors["sessA"].core.pos, Some((-867.0, 400.0))); + } + #[test] fn seed_does_not_resurrect_ended_session() { let mut map = empty_map(); @@ -1823,16 +1830,16 @@ mod tests { } #[test] - fn sentinel_cursor_is_quiescent_no_frame_tick() { + fn unplaced_cursor_is_quiescent_no_frame_tick() { // A brand-new `mcp`/`serve` with no agent activity holds only the - // "default" cursor at the off-screen sentinel (-200, -200). It must NOT + // unplaced "default" cursor. It must NOT // request frame ticks, so the render timer can drop to the slow idle // cadence instead of compositing a full-screen pixmap at ~125 Hz // (issue #1808 idle-CPU burn). let map = empty_map(); assert!( !render_map_needs_frame_tick(&map), - "an untouched sentinel-only overlay must be quiescent" + "an untouched unplaced overlay must be quiescent" ); } diff --git a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs index ef1477e1d6..4993a1f47b 100644 --- a/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-windows/src/tools/impl_.rs @@ -231,9 +231,8 @@ fn screen_to_bitmap(hwnd: u64, sx: i32, sy: i32) -> (i32, i32) { /// Animate the agent cursor to (sx, sy) in screen coordinates and wait for the /// glide to finish before returning. No-op when the overlay is not enabled. /// -/// On the very first call the cursor is at the off-screen initial position -/// (-200, -200). Animating from there would cause a jarring off-screen fly-in, -/// so we snap to the target with a ClickPulse first and skip the glide wait. +/// On the first call the cursor has no position, so we place it with a +/// `ClickPulse` and skip the glide wait. /// /// For all subsequent calls this defers to /// [`crate::overlay::animate_cursor_to`], which sends `MoveTo` and awaits the @@ -252,7 +251,7 @@ async fn overlay_glide_to(key: &str, sx: f64, sy: f64) { return; } let pos = crate::overlay::current_position(key); - if pos.0 < 0.0 && pos.1 < 0.0 { + if pos.is_none() { // Snap to target on first use; no animation to wait for. crate::overlay::send_command( key.to_owned(), From c0b02d0832382239aba6bb4196a111c3fb180bb5 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 13:56:09 -0500 Subject: [PATCH 108/117] fix(cua-driver): align Wayland desktop capture and input pixels Normalize native Wayland captures into the reported desktop action frame and expose the real backing scale so screenshot-grounded input and cursor overlays share one coordinate system. Fail closed instead of distorting nonuniform layouts.\n\nRefs #3061.\n\nCo-authored-by: Pagani <33448453+pagan1e@users.noreply.github.com> --- .../crates/platform-linux/src/tools/impl_.rs | 103 ++++++++++++++++-- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index e71a9c8f34..aea6ded155 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -6316,6 +6316,46 @@ fn x11_screen_size() -> anyhow::Result<(u32, u32)> { Ok((w, h)) } +/// Put the desktop image in the exact coordinate frame consumed by desktop +/// actions. Native Wayland capture buffers may use backing pixels while the +/// compositor's pointer protocol and reported screen geometry use logical +/// pixels. Returning the backing image alongside logical dimensions violates +/// the screenshot-to-action contract and makes every vision-grounded action +/// miss by the output scale. +fn normalize_desktop_capture_for_action_frame( + png: Vec, + action_width: u32, + action_height: u32, +) -> anyhow::Result<(Vec, u32, u32, f64)> { + if action_width == 0 || action_height == 0 { + anyhow::bail!("desktop action frame is empty: {action_width}x{action_height}"); + } + + let (capture_width, capture_height) = crate::capture::png_dimensions_pub(&png)?; + let scale_x = f64::from(capture_width) / f64::from(action_width); + let scale_y = f64::from(capture_height) / f64::from(action_height); + if (scale_x - scale_y).abs() > 0.01 { + anyhow::bail!( + "desktop capture {capture_width}x{capture_height} cannot be mapped uniformly to \ + action frame {action_width}x{action_height} (scale {scale_x:.4}x{scale_y:.4})" + ); + } + + if capture_width == action_width && capture_height == action_height { + return Ok((png, action_width, action_height, 1.0)); + } + + let image = image::load_from_memory_with_format(&png, image::ImageFormat::Png)?; + let resized = image.resize_exact( + action_width, + action_height, + image::imageops::FilterType::Lanczos3, + ); + let mut encoded = std::io::Cursor::new(Vec::new()); + resized.write_to(&mut encoded, image::ImageFormat::Png)?; + Ok((encoded.into_inner(), action_width, action_height, scale_x)) +} + // ── get_desktop_state ───────────────────────────────────────────────────────── pub struct GetDesktopStateTool; @@ -6326,8 +6366,8 @@ impl Tool for GetDesktopStateTool { fn def(&self) -> &ToolDef { GDS_DEF.get_or_init(|| ToolDef { name: "get_desktop_state".into(), - description: "Capture the full display in true screen pixels with no downscale. \ - Use its native-size PNG as the coordinate source for actions whose target is \ + description: "Capture the full display in the desktop action coordinate frame. \ + Use the returned PNG directly as the coordinate source for actions whose target is \ {kind:\"desktop\",display_id:\"primary\"}. No AT-SPI walk.".into(), input_schema: json!({"type":"object","properties":{ "session":{"type":"string","description":"For multi-call work, prefer a short public session label and repeat it on every call that accepts it. Omit it to use the authenticated transport's implicit lifecycle session."}, @@ -6345,10 +6385,11 @@ impl Tool for GetDesktopStateTool { let out_file = input.screenshot_out_file; let result = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { - // Vision-only: capture the FULL DISPLAY at native size. No downscale - // so screen-absolute pixels land exactly. - let png = crate::capture::screenshot_display_bytes()?; - let (shot_w, shot_h) = crate::capture::png_dimensions_pub(&png)?; + // Capture the full display at native size first. When the + // compositor consumes logical input coordinates, normalize the + // image below so screenshot pixels still land exactly. + let native_png = crate::capture::screenshot_display_bytes()?; + let (native_w, native_h) = crate::capture::png_dimensions_pub(&native_png)?; // True screen size. On a pure-Wayland session (native backend // opted in, no X11 DISPLAY) the capture above came from the // wlroots `zwlr_screencopy` cascade, whose full-display buffer is @@ -6359,10 +6400,12 @@ impl Tool for GetDesktopStateTool { // Only fall back to the X11 root-window geometry off Wayland, so // the X11 / XWayland path is unchanged. See #2017 / Sway testing. let (screen_w, screen_h) = if crate::wayland::is_wayland() { - (shot_w, shot_h) + (native_w, native_h) } else { x11_screen_size()? }; + let (png, shot_w, shot_h, scale_factor) = + normalize_desktop_capture_for_action_frame(native_png, screen_w, screen_h)?; // Optional: write PNG to disk instead of returning base64. let written = if let Some(path) = out_file.as_deref() { std::fs::write(path, &png)?; @@ -6376,12 +6419,20 @@ impl Tool for GetDesktopStateTool { } else { Some(B64.encode(&png)) }; - Ok((b64, shot_w, shot_h, screen_w, screen_h, written)) + Ok(( + b64, + shot_w, + shot_h, + screen_w, + screen_h, + scale_factor, + written, + )) }) .await; match result { - Ok(Ok((b64_opt, shot_w, shot_h, screen_w, screen_h, written))) => { + Ok(Ok((b64_opt, shot_w, shot_h, screen_w, screen_h, scale_factor, written))) => { let mut content = Vec::new(); let mut structured = json!({ "platform": "linux", @@ -6390,7 +6441,7 @@ impl Tool for GetDesktopStateTool { "screenshot_height": shot_h, "screen_width": screen_w, "screen_height": screen_h, - "scale_factor": 1.0, + "scale_factor": scale_factor, "screenshot_mime_type": "image/png", }); if let Some(b64) = b64_opt { @@ -8155,3 +8206,35 @@ mod pid_window_target_tests { )); } } + +#[cfg(test)] +mod desktop_capture_frame_tests { + use super::normalize_desktop_capture_for_action_frame; + + fn png(width: u32, height: u32) -> Vec { + let rgba = vec![0x7f; (width * height * 4) as usize]; + cua_driver_core::image_utils::encode_rgba_to_png(&rgba, width, height) + .expect("encode fixture") + } + + #[test] + fn native_wayland_capture_is_resized_to_the_reported_action_frame() { + let (normalized, width, height, scale) = + normalize_desktop_capture_for_action_frame(png(3200, 2000), 1600, 1000) + .expect("normalize 2x capture"); + + assert_eq!((width, height), (1600, 1000)); + assert_eq!( + cua_driver_core::image_utils::png_dimensions(&normalized).unwrap(), + (1600, 1000) + ); + assert_eq!(scale, 2.0); + } + + #[test] + fn nonuniform_capture_mapping_fails_instead_of_distorting_coordinates() { + let error = normalize_desktop_capture_for_action_frame(png(3200, 2000), 1600, 1200) + .expect_err("nonuniform mapping must fail closed"); + assert!(error.to_string().contains("cannot be mapped uniformly")); + } +} From 0bde8d32350a23b59091db143870fc5b165af568 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 14:06:30 -0500 Subject: [PATCH 109/117] fix(cua-driver): verify Wayland window activation --- .../crates/platform-linux/src/wayland/mod.rs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 3432293834..631a47337c 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -208,6 +208,7 @@ struct Toplevel { title: String, app_id: String, closed: bool, + activated: bool, } #[derive(Clone, Debug)] @@ -594,12 +595,21 @@ impl Dispatch for State { match event { ftl_handle::Event::Title { title } => tl.title = title, ftl_handle::Event::AppId { app_id } => tl.app_id = app_id, + ftl_handle::Event::State { state } => { + tl.activated = foreign_toplevel_state_is_activated(&state) + } ftl_handle::Event::Closed => tl.closed = true, _ => {} } } } +fn foreign_toplevel_state_is_activated(state: &[u8]) -> bool { + state + .chunks_exact(std::mem::size_of::()) + .any(|bytes| u32::from_ne_bytes(bytes.try_into().expect("four-byte state")) == 2) +} + /// Enumerate native Wayland toplevels via wlr-foreign-toplevel-management. /// `xid` begins as the foreign-toplevel handle's connection-scoped protocol id. /// The dispatcher replaces it with a stable compositor or AT-SPI identity when @@ -1327,10 +1337,23 @@ pub fn activate_window_for_input_target( state.seat.clone(), matching_handle(&state, window_id), ) { + let protocol_id = handle.id().protocol_id(); handle.activate(&seat); - queue.roundtrip(&mut state)?; - std::thread::sleep(std::time::Duration::from_millis(60)); - return Ok(()); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + loop { + queue.roundtrip(&mut state)?; + if state + .toplevels + .get(&protocol_id) + .is_some_and(|toplevel| toplevel.activated) + { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } } if shell_helper::activate_window(window_id) { @@ -3192,6 +3215,7 @@ fn enrich_native_windows( title: undecorated_native_title(window).to_owned(), app_id: window.app_name.clone(), closed: false, + activated: false, }; window.xid = candidate.xid; remember_identity(window.xid, &toplevel); @@ -3579,6 +3603,26 @@ mod tests { assert_eq!((decoded.width(), decoded.height()), (3, 4)); } + #[test] + fn foreign_toplevel_state_requires_activated_value() { + let states = [0_u32, 2_u32, 3_u32] + .into_iter() + .flat_map(u32::to_ne_bytes) + .collect::>(); + assert!(foreign_toplevel_state_is_activated(&states)); + + let inactive = [0_u32, 1_u32, 3_u32] + .into_iter() + .flat_map(u32::to_ne_bytes) + .collect::>(); + assert!(!foreign_toplevel_state_is_activated(&inactive)); + } + + #[test] + fn foreign_toplevel_state_ignores_incomplete_wire_values() { + assert!(!foreign_toplevel_state_is_activated(&[2, 0, 0])); + } + #[test] fn shell_helper_capture_failure_is_terminal() { let result = checked_shell_helper_capture(true, || None) From 0a908f0ebac277bbc4c7a5f77536d3cd5faa96e0 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 14:20:12 -0500 Subject: [PATCH 110/117] fix(cua-driver): avoid duplicate X11 overlay on Wayland --- .../rust/crates/platform-linux/src/overlay.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs index 710aa22e01..f9e228077e 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs @@ -114,6 +114,10 @@ fn try_send_x11_message( sender.is_some_and(|tx| tx.try_send(msg).is_ok()) } +fn should_start_x11_overlay(native_wayland: bool) -> bool { + !native_wayland +} + #[cfg(target_os = "linux")] struct X11OverlayThreadCleanup { receiver: Option>, @@ -299,8 +303,9 @@ fn try_send_command_for(key: CursorKey, cmd: OverlayCommand) -> bool { key: key.clone(), cmd: cmd.clone(), }); - let x11_queued = try_send_x11_message(CMD_TX.get(), msg.clone()); - if !x11_queued { + let native_wayland = crate::wayland::is_wayland(); + let x11_queued = !native_wayland && try_send_x11_message(CMD_TX.get(), msg.clone()); + if !native_wayland && !x11_queued { tracing::warn!( key = %key, sender_missing = CMD_TX.get().is_none(), @@ -579,6 +584,15 @@ pub fn run_on_thread() { return; } + // A native Wayland session may also expose DISPLAY through XWayland, but + // that does not make the legacy full-root X11 overlay authoritative. The + // command path below forwards to the layer-shell backend; running both + // produces two independently scaled cursors and lets X11 save-unders leak + // into Wayland desktop captures. + if !should_start_x11_overlay(crate::wayland::is_wayland()) { + return; + } + // Wayland layer-shell overlay is started LAZILY on the first // send_command_for() that targets a Wayland session (see the // wayland::overlay::forward() path). Starting it eagerly here added @@ -2710,6 +2724,12 @@ fn bgra_and_visible_shape( mod tests { use super::*; + #[test] + fn native_wayland_does_not_start_legacy_x11_overlay() { + assert!(!should_start_x11_overlay(true)); + assert!(should_start_x11_overlay(false)); + } + fn drain_x11_test_events(conn: &impl x11rb::connection::Connection) -> anyhow::Result<()> { while conn.poll_for_event()?.is_some() {} Ok(()) From f34cea7f4ba7ad866ee9e01fcbec0d9714235f49 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 14:20:36 -0500 Subject: [PATCH 111/117] fix(cua-driver): synchronize Hyprland virtual-pointer motion --- .../rust/crates/platform-linux/src/wayland/mod.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 1b3752d85a..8fa843a07f 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -1372,8 +1372,15 @@ impl VptrSession { if hyprland::is_session() { // Hyprland has known multi-output normalization defects for // virtual-pointer absolute motion. Its compositor dispatcher uses - // the exact same global logical coordinates as client geometry; - // keep virtual-pointer for the subsequent button/axis event only. + // the exact same global logical coordinates as client geometry. + // Still update the virtual device before its button/axis event: + // moving only the seat cursor leaves the virtual pointer at its + // previous location, so its press can target a different surface + // than the cursor shown to the user. + self.vptr + .motion_absolute(event_time_ms(), px, py, self.output_w, self.output_h); + self.vptr.frame(); + self.queue.roundtrip(&mut self.state)?; hyprland::move_cursor(global_x, global_y)?; } else { self.vptr From 4bf5d229b4d45dd119daee0132dfcedb453b8d68 Mon Sep 17 00:00:00 2001 From: Jacob Mink Date: Thu, 13 Aug 2026 22:24:47 -0500 Subject: [PATCH 112/117] fix(cua-driver): suppress X11 overlay on Wayland displays (cherry picked from commit 904235732dbeb71fe71db17d029ce976e62688ab) --- .../rust/crates/platform-linux/src/overlay.rs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs index a5ca79e7d6..f79ec0cc16 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs @@ -114,8 +114,8 @@ fn try_send_x11_message( sender.is_some_and(|tx| tx.try_send(msg).is_ok()) } -fn should_start_x11_overlay(native_wayland: bool) -> bool { - !native_wayland +fn should_start_x11_overlay(wayland_display_present: bool) -> bool { + !wayland_display_present } #[cfg(target_os = "linux")] @@ -304,8 +304,10 @@ fn try_send_command_for(key: CursorKey, cmd: OverlayCommand) -> bool { cmd: cmd.clone(), }); let native_wayland = crate::wayland::is_wayland(); - let x11_queued = !native_wayland && try_send_x11_message(CMD_TX.get(), msg.clone()); - if !native_wayland && !x11_queued { + let x11_overlay_allowed = + should_start_x11_overlay(std::env::var_os("WAYLAND_DISPLAY").is_some()); + let x11_queued = x11_overlay_allowed && try_send_x11_message(CMD_TX.get(), msg.clone()); + if x11_overlay_allowed && !x11_queued { tracing::warn!( key = %key, sender_missing = CMD_TX.get().is_none(), @@ -317,7 +319,7 @@ fn try_send_command_for(key: CursorKey, cmd: OverlayCommand) -> bool { // owner thread isn't started yet (which is the normal X11-only case). #[cfg(target_os = "linux")] { - if crate::wayland::is_wayland() { + if native_wayland { if crate::wayland::shell_helper::semantic_cursor_available() { crate::wayland::shell_helper::set_cursor_color(&cursor_overlay::session_fill_hex( &key, @@ -575,12 +577,14 @@ pub fn run_on_thread() { return; } - // A native Wayland session may also expose DISPLAY through XWayland, but - // that does not make the legacy full-root X11 overlay authoritative. The - // command path below forwards to the layer-shell backend; running both - // produces two independently scaled cursors and lets X11 save-unders leak - // into Wayland desktop captures. - if !should_start_x11_overlay(crate::wayland::is_wayland()) { + // A Wayland session normally also exposes DISPLAY through XWayland, but + // that does not make the legacy full-root X11 overlay safe. This decision + // must be independent of the experimental native-Wayland feature opt-in: + // without that opt-in there is no layer-shell fallback, but showing no + // overlay is preferable to mapping an opaque black X11 root window over + // the Wayland desktop. With the opt-in enabled, commands are forwarded to + // the native layer-shell backend below. + if !should_start_x11_overlay(std::env::var_os("WAYLAND_DISPLAY").is_some()) { return; } @@ -2710,7 +2714,7 @@ mod tests { use super::*; #[test] - fn native_wayland_does_not_start_legacy_x11_overlay() { + fn wayland_display_does_not_start_legacy_x11_overlay() { assert!(!should_start_x11_overlay(true)); assert!(should_start_x11_overlay(false)); } From 71bb9f1ed7bcf72fd02d38dec819cb6094fbf810 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Wed, 12 Aug 2026 00:13:10 -0500 Subject: [PATCH 113/117] fix(cua-driver): keep session cursor visible during Linux input (cherry picked from commit 2f8b49aedeebd5f9c140708593af17bc33e63a4c) --- .../rust/crates/cursor-overlay/src/lib.rs | 18 + .../crates/cursor-overlay/src/render_state.rs | 13 + .../rust/crates/platform-linux/src/overlay.rs | 21 + .../crates/platform-linux/src/tools/impl_.rs | 503 +++++++++++++----- 4 files changed, 422 insertions(+), 133 deletions(-) diff --git a/libs/cua-driver/rust/crates/cursor-overlay/src/lib.rs b/libs/cua-driver/rust/crates/cursor-overlay/src/lib.rs index 185c7f341b..be79e5b15d 100644 --- a/libs/cua-driver/rust/crates/cursor-overlay/src/lib.rs +++ b/libs/cua-driver/rust/crates/cursor-overlay/src/lib.rs @@ -407,4 +407,22 @@ mod pointer_tracking_tests { assert!((x - (120.0 + heading.cos() * 16.0)).abs() < f64::EPSILON); assert!((y - (80.0 + heading.sin() * 16.0)).abs() < f64::EPSILON); } + + #[test] + fn session_cleanup_removes_named_cursor_but_preserves_anonymous_default() { + let registry = CursorRegistry::new(); + registry.update_position("session-a", 12.0, 34.0); + registry.update_position("default", 56.0, 78.0); + + registry.remove("session-a"); + registry.remove("default"); + + assert!(registry.get("session-a").is_none()); + assert_eq!( + registry + .get("default") + .and_then(|cursor| cursor.x.zip(cursor.y)), + Some((56.0, 78.0)) + ); + } } diff --git a/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs b/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs index 603901d3e0..1f5da81439 100644 --- a/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs +++ b/libs/cua-driver/rust/crates/cursor-overlay/src/render_state.rs @@ -1028,6 +1028,19 @@ mod session_badge_and_action_tests { use super::*; use crate::{CursorConfig, DeliveryModifier, TargetModifier}; + #[test] + fn idle_hide_zero_keeps_a_positioned_session_cursor_visible() { + let mut core = RenderStateCore::new(CursorConfig::default()); + core.motion.idle_hide_ms = 0.0; + assert!(core.apply_command_base(OverlayCommand::ClickPulse { x: 40.0, y: 60.0 }, false,)); + + core.tick_motion(2.0); + + assert!(core.cursor_is_revealed()); + assert!(core.pos.is_some()); + assert_eq!(core.idle_alpha, 1.0); + } + #[test] fn session_badge_holds_then_fades_once() { let mut core = RenderStateCore::new(CursorConfig::default()); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs index f79ec0cc16..1d85503b0a 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/overlay.rs @@ -3161,6 +3161,27 @@ mod tests { let _: fn(CursorKey, OverlayCommand) = send_command_for; } + #[test] + fn session_removal_drops_the_cursor_and_rejects_late_commands() { + let mut map = default_render_map(); + let command = || { + OverlayMsg::Cmd(KeyedOverlayCommand { + key: "session-a".to_owned(), + cmd: OverlayCommand::ClickPulse { x: 12.0, y: 34.0 }, + }) + }; + + assert_eq!(apply_msg(&mut map, command()).as_deref(), Some("session-a")); + assert!(map.cursors.contains_key("session-a")); + + assert!(apply_msg(&mut map, OverlayMsg::Remove("session-a".to_owned())).is_none()); + assert!(!map.cursors.contains_key("session-a")); + assert!(map.ended.contains("session-a")); + + assert!(apply_msg(&mut map, command()).is_none()); + assert!(!map.cursors.contains_key("session-a")); + } + #[test] fn failed_x11_enqueue_is_reported_without_waiting() { assert!(!try_send_x11_message(None, test_message())); diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index a28b7feb38..f066d31797 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -1990,33 +1990,34 @@ fn resolve_cursor_key(args: &Value) -> String { "default".to_owned() } -#[cfg(test)] -mod cursor_key_resolution_tests { - use super::resolve_cursor_key; - use serde_json::json; +/// Return the cursor key only for a lifecycle-owned session. Cursor positioning +/// for keyboard actions deliberately does not opt anonymous calls or the +/// legacy cursor_id-only path into session semantics. The proxy-minted +/// `_session_id` is trusted lifecycle state and must behave like a public named +/// session for cursor ownership. +fn named_session_cursor_key(args: &Value) -> Option { + ["session", "_session_id"].into_iter().find_map(|key| { + args.get(key) + .and_then(Value::as_str) + .filter(|session| !session.is_empty()) + .map(str::to_owned) + }) +} - #[test] - fn trusted_implicit_session_owns_the_cursor() { - assert_eq!(resolve_cursor_key(&json!({})), "default"); - assert_eq!( - resolve_cursor_key(&json!({"_session_id": "implicit-lease"})), - "implicit-lease" - ); - assert_eq!( - resolve_cursor_key(&json!({ - "_session_id": "implicit-lease", - "cursor_id": "legacy" - })), - "implicit-lease" - ); - assert_eq!( - resolve_cursor_key(&json!({ - "session": "named", - "_session_id": "implicit-lease" - })), - "named" - ); - } +fn finite_cursor_point(point: Option<(f64, f64)>) -> Option<(f64, f64)> { + point.filter(|(x, y)| x.is_finite() && y.is_finite()) +} + +fn choose_keyboard_cursor_target( + explicit: Option<(f64, f64)>, + remembered: Option<(f64, f64)>, + window_center: Option<(f64, f64)>, + current_pointer: Option<(f64, f64)>, +) -> Option<(f64, f64)> { + finite_cursor_point(explicit) + .or_else(|| finite_cursor_point(remembered)) + .or_else(|| finite_cursor_point(window_center)) + .or_else(|| finite_cursor_point(current_pointer)) } fn mouse_hold_json(cursor_id: &str, hold: Option<&MouseHoldState>) -> Value { @@ -2118,6 +2119,146 @@ async fn overlay_glide_to_for(cursor_id: &str, sx: f64, sy: f64) { crate::overlay::animate_cursor_to_for(cursor_id.to_owned(), sx, sy).await; } +/// Keep the logical cursor position in sync with every visibly targeted +/// pointer action. Overlay delivery is intentionally best-effort: registry +/// state is still updated when no renderer is running or its queue is closed. +async fn reveal_pointer_action_for( + state: &ToolState, + cursor_id: &str, + sx: f64, + sy: f64, + click_pulse: bool, +) { + if !sx.is_finite() || !sy.is_finite() { + return; + } + state.cursor_registry.update_position(cursor_id, sx, sy); + overlay_glide_to_for(cursor_id, sx, sy).await; + if click_pulse { + crate::overlay::send_command_for( + cursor_id.to_owned(), + cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, + ); + } +} + +fn keyboard_window_center(xid: u64) -> Option<(f64, f64)> { + if xid == 0 { + return None; + } + if crate::wayland::is_wayland() { + return crate::wayland::window_geometry(xid).and_then(|(x, y, width, height)| { + (width > 0 && height > 0).then_some(( + f64::from(x) + f64::from(width) / 2.0, + f64::from(y) + f64::from(height) / 2.0, + )) + }); + } + window_screen_center(xid) + .ok() + .map(|(x, y)| (f64::from(x), f64::from(y))) +} + +fn current_pointer_position() -> Option<(f64, f64)> { + if crate::wayland::is_wayland() { + return crate::wayland::last_synth_cursor_pos().map(|(x, y)| (f64::from(x), f64::from(y))); + } + + use x11rb::connection::Connection; + use x11rb::protocol::xproto::ConnectionExt as _; + use x11rb::rust_connection::RustConnection; + + let (connection, screen_num) = RustConnection::connect(None).ok()?; + let root = connection.setup().roots[screen_num].root; + let reply = connection.query_pointer(root).ok()?.reply().ok()?; + Some((f64::from(reply.root_x), f64::from(reply.root_y))) +} + +fn explicit_keyboard_cursor_target( + pid: u32, + xid: u64, + element_index: Option, + pixel_target: Option<(f64, f64)>, +) -> Option<(f64, f64)> { + if let Some(element_index) = element_index { + let (sx, sy) = element_screen_center(pid, element_index).ok()?; + return Some((sx, sy)); + } + + let (x, y) = pixel_target?; + if crate::wayland::wayland_input_enabled() { + return crate::wayland::window_geometry(xid) + .map(|(wx, wy, _, _)| (f64::from(wx) + x.round(), f64::from(wy) + y.round())); + } + window_local_to_screen(xid, x, y).ok() +} + +/// Position and reveal a named session's cursor before keyboard/value input. +/// `preserve_legacy_element_visual` retains the existing element-only feedback +/// for type_text/set_value anonymous calls without adding session-style fallback +/// placement to them. Geometry and overlay failures are observational and never +/// affect the tool's actual input result. +async fn position_named_session_keyboard_cursor( + state: &ToolState, + args: &Value, + pid: u32, + xid: u64, + element_index: Option, + pixel_target: Option<(f64, f64)>, + preserve_legacy_element_visual: bool, +) { + let named_cursor_id = named_session_cursor_key(args); + let cursor_id = match named_cursor_id { + Some(ref cursor_id) => cursor_id.clone(), + None if preserve_legacy_element_visual && element_index.is_some() => { + resolve_cursor_key(args) + } + None => return, + }; + + let remembered = named_cursor_id.as_ref().and_then(|_| { + state + .cursor_registry + .get(&cursor_id) + .and_then(|cursor| cursor.x.zip(cursor.y)) + }); + let explicit = tokio::task::spawn_blocking(move || { + explicit_keyboard_cursor_target(pid, xid, element_index, pixel_target) + }) + .await + .ok() + .flatten(); + + let fallback = if named_cursor_id.is_some() + && explicit.is_none() + && finite_cursor_point(remembered).is_none() + { + tokio::task::spawn_blocking(move || { + let center = keyboard_window_center(xid); + let pointer = center.is_none().then(current_pointer_position).flatten(); + (center, pointer) + }) + .await + .unwrap_or((None, None)) + } else { + (None, None) + }; + + let Some((sx, sy)) = + choose_keyboard_cursor_target(explicit, remembered, fallback.0, fallback.1) + else { + return; + }; + if xid != 0 { + crate::overlay::send_command_for( + cursor_id.clone(), + cursor_overlay::OverlayCommand::PinAbove(xid), + ); + } + state.cursor_registry.update_position(&cursor_id, sx, sy); + overlay_glide_to_for(&cursor_id, sx, sy).await; +} + async fn track_overlay_drag_for( cursor_id: String, from: (f64, f64), @@ -2369,7 +2510,8 @@ impl Tool for ClickTool { // / Windows desktop paths already do this). Without it the overlay // sits idle elsewhere while only the real pointer warps, so a viewer // sees the cursor "click somewhere else." - overlay_glide_to_for(&cursor_id, sx as f64, sy as f64).await; + reveal_pointer_action_for(&self.state, &cursor_id, f64::from(sx), f64::from(sy), true) + .await; let r = tokio::task::spawn_blocking(move || { if crate::wayland::wayland_input_enabled() { if !modifiers.is_empty() { @@ -2483,11 +2625,7 @@ impl Tool for ClickTool { cursor_overlay::OverlayCommand::PinAbove(xid), ); } - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; // Chromium can execute a genuine AT-SPI action without focus. Try // that route before applying its background synthetic-input gate. @@ -2625,11 +2763,7 @@ impl Tool for ClickTool { .and_then(|r| r.ok()) }; if let Some((sx, sy)) = glide_target { - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; } let (xi, yi) = (x as i32, y as i32); @@ -2899,6 +3033,8 @@ impl Tool for TypeTextTool { let text = cua_driver_core::text_sanitize::strip_trailing_agent_protocol_tags(&input.text) .into_owned(); + position_named_session_keyboard_cursor(&self.state, &args, 0, 0, None, None, false) + .await; let wayland = crate::wayland::wayland_input_enabled(); let path = if wayland { "wayland_focused" } else { "xtest" }; let result = tokio::task::spawn_blocking(move || { @@ -2990,21 +3126,16 @@ impl Tool for TypeTextTool { ); } - let cursor_id = resolve_cursor_key(&args); - if let Some(idx) = resolved_elem_idx { - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::PinAbove(xid), - ); - if let Ok(Ok((screen_x, screen_y))) = - tokio::task::spawn_blocking(move || element_screen_center(pid, idx)).await - { - overlay_glide_to_for(&cursor_id, screen_x, screen_y).await; - self.state - .cursor_registry - .update_position(&cursor_id, screen_x, screen_y); - } - } + position_named_session_keyboard_cursor( + &self.state, + &args, + pid, + xid, + resolved_elem_idx, + px.zip(py), + true, + ) + .await; let text_len = text.chars().count(); // Native toolkit editables have a stronger focus-free route than raw @@ -3516,6 +3647,8 @@ impl Tool for PressKeyTool { let key = input.key; let display = key.clone(); let modifiers = input.modifiers.unwrap_or_default(); + position_named_session_keyboard_cursor(&self.state, &args, 0, 0, None, None, false) + .await; let wayland = crate::wayland::wayland_input_enabled(); let path = if wayland { "wayland_focused" } else { "xtest" }; let result = tokio::task::spawn_blocking(move || { @@ -3565,18 +3698,18 @@ impl Tool for PressKeyTool { Ok(r) => r, Err(e) => return e, }; + let resolved_element_index = match &resolved { + cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { + Some(*element_index) + } + cua_driver_core::element_token::ResolvedElement::None => None, + }; let xid_opt = match &resolved { cua_driver_core::element_token::ResolvedElement::Element { window_id, .. } => { window_id_arg.or_else(|| window_id.map(|v| v as u64)) } cua_driver_core::element_token::ResolvedElement::None => window_id_arg, }; - let resolved_element_idx = match &resolved { - cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { - Some(*element_index) - } - cua_driver_core::element_token::ResolvedElement::None => None, - }; let xid = match xid_opt { Some(x) => x, None => { @@ -3619,17 +3752,28 @@ impl Tool for PressKeyTool { if px.is_some() != py.is_some() { return ToolResult::error("Pass both x and y to press_key, or neither."); } - if px.is_some() && resolved_element_idx.is_some() { + if px.is_some() && resolved_element_index.is_some() { return ToolResult::error( "Pass either element_index (ax) or x,y (px) to press_key, not both.", ); } + position_named_session_keyboard_cursor( + &self.state, + &args, + pid, + xid, + resolved_element_index, + px.zip(py), + false, + ) + .await; + // Nested cua-compositor addresses the owning Wayland client directly. // Preserve legacy modifiers by promoting the request to a chord. if crate::wayland::is_inject_mode() { if let Err(error) = - focus_nested_inject_target(pid, xid, resolved_element_idx, px.zip(py)).await + focus_nested_inject_target(pid, xid, resolved_element_index, px.zip(py)).await { return error; } @@ -3687,7 +3831,7 @@ impl Tool for PressKeyTool { if crate::wayland::wayland_input_enabled() { let key_w = key.clone(); let chord = press_key_chord(&mods, &key); - let idx = resolved_element_idx; + let idx = resolved_element_index; let result = tokio::task::spawn_blocking(move || { crate::wayland::with_target_foreground(pid, xid, || { if let Some(idx) = idx { @@ -3719,7 +3863,7 @@ impl Tool for PressKeyTool { // click restores the prior top-level before returning. let deliver_fg = delivery.is_foreground(); let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - if resolved_element_idx.is_none() + if resolved_element_index.is_none() && mods.is_empty() && key_for_task.eq_ignore_ascii_case("enter") { @@ -3735,7 +3879,7 @@ impl Tool for PressKeyTool { // XSendEvent (no focus steal) for apps that accept it. if deliver_fg { return crate::input::with_x11_foreground(xid, 80, || { - if let Some(element_index) = resolved_element_idx { + if let Some(element_index) = resolved_element_index { if !crate::atspi::focus_element(pid, element_index)? { anyhow::bail!( "AT-SPI Component.GrabFocus returned false for element {element_index}" @@ -3745,7 +3889,7 @@ impl Tool for PressKeyTool { crate::input::send_key_xtest(&key_for_task, &m) }); } - if let Some(element_index) = resolved_element_idx { + if let Some(element_index) = resolved_element_index { if !crate::atspi::focus_element(pid, element_index)? { anyhow::bail!( "AT-SPI Component.GrabFocus returned false for element {element_index}" @@ -3846,6 +3990,8 @@ impl Tool for HotkeyTool { return ToolResult::error("keys must include at least one non-modifier key."); }; let display = keys.join("+"); + position_named_session_keyboard_cursor(&self.state, &args, 0, 0, None, None, false) + .await; let wayland = crate::wayland::wayland_input_enabled(); let path = if wayland { "wayland_focused" } else { "xtest" }; let result = tokio::task::spawn_blocking(move || { @@ -3962,6 +4108,17 @@ impl Tool for HotkeyTool { ); } + position_named_session_keyboard_cursor( + &self.state, + &args, + pid, + xid, + resolved_element_index, + px.zip(py), + false, + ) + .await; + if crate::wayland::is_inject_mode() { if let Err(error) = focus_nested_inject_target(pid, xid, resolved_element_index, px.zip(py)).await @@ -4106,7 +4263,9 @@ impl Tool for HotkeyTool { // ── set_value ───────────────────────────────────────────────────────────────── -pub struct SetValueTool; +pub struct SetValueTool { + state: Arc, +} static SV_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); #[async_trait] @@ -4152,32 +4311,23 @@ impl Tool for SetValueTool { Ok(r) => r, Err(e) => return e, }; - let idx = match resolved { - cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { - element_index - } + let (idx, resolved_window_id) = match &resolved { + cua_driver_core::element_token::ResolvedElement::Element { + element_index, + window_id, + .. + } => (*element_index, window_id.map(u64::from)), cua_driver_core::element_token::ResolvedElement::None => return ToolResult::error( "set_value requires element_index or element_token to address the target element.", ), }; - let cursor_id = resolve_cursor_key(&args); let value_for_task = value.clone(); - // Pulse the agent cursor onto the target element before writing, so a - // value write gets the same visual feedback as a click — the viewer can - // see *where* the agent is acting. No-op when the element bounds can't - // be resolved or the overlay is disabled. - if let Ok(Ok((sx, sy))) = - tokio::task::spawn_blocking(move || element_screen_center(pid, idx)).await - { - let window_id = args.u64_or("window_id", 0); - if window_id != 0 { - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::PinAbove(window_id), - ); - } - overlay_glide_to_for(&cursor_id, sx, sy).await; - } + let xid = args + .opt_u64("window_id") + .or(resolved_window_id) + .unwrap_or(0); + position_named_session_keyboard_cursor(&self.state, &args, pid, xid, Some(idx), None, true) + .await; let result = tokio::task::spawn_blocking(move || crate::atspi::set_value(pid, idx, &value_for_task)) .await; @@ -4245,6 +4395,16 @@ impl Tool for ScrollTool { let display = direction.clone(); let wayland = crate::wayland::wayland_input_enabled(); let path = if wayland { "wayland_desktop" } else { "xtest" }; + if named_session_cursor_key(&args).is_some() { + reveal_pointer_action_for( + &self.state, + &cursor_id, + f64::from(x), + f64::from(y), + false, + ) + .await; + } let result = tokio::task::spawn_blocking(move || { if wayland { crate::wayland::scroll_desktop(x, y, &direction, amount as u32) @@ -4313,6 +4473,49 @@ impl Tool for ScrollTool { } }; + let pixel_target = match ( + args.get("x").and_then(|value| value.as_f64()), + args.get("y").and_then(|value| value.as_f64()), + ) { + (Some(x), Some(y)) => { + // Pixel targets use the latest screenshot's coordinate frame. + // Apply the same buffer-to-window ratio as click/drag before + // positioning either the agent cursor or the input device. + let ratio = self.state.resize_registry.ratio(pid).unwrap_or(1.0); + Some((x * ratio, y * ratio)) + } + (None, None) => None, + _ => return ToolResult::error("Pass both x and y to pixel-target scroll."), + }; + let resolved_element_index = match &resolved { + cua_driver_core::element_token::ResolvedElement::Element { element_index, .. } => { + Some(*element_index) + } + cua_driver_core::element_token::ResolvedElement::None => None, + }; + if pixel_target.is_some() && resolved_element_index.is_some() { + return ToolResult::error( + "Pass either element_index (ax) or x,y (px) to scroll, not both.", + ); + } + + if named_session_cursor_key(&args).is_some() { + let visual_target = tokio::task::spawn_blocking(move || { + explicit_keyboard_cursor_target(pid, xid, resolved_element_index, pixel_target) + .or_else(|| keyboard_window_center(xid)) + }) + .await + .ok() + .flatten(); + if let Some((sx, sy)) = visual_target { + crate::overlay::send_command_for( + cursor_id.clone(), + cursor_overlay::OverlayCommand::PinAbove(xid), + ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, false).await; + } + } + let delivery = crate::input::delivery::DeliveryMode::from_args(&args); if let Some(refusal) = unavailable_chromium_background(pid, delivery) { return refusal; @@ -4349,32 +4552,6 @@ impl Tool for ScrollTool { } } - let pixel_target = match ( - args.get("x").and_then(|value| value.as_f64()), - args.get("y").and_then(|value| value.as_f64()), - ) { - (Some(x), Some(y)) => { - // Pixel targets are expressed in the latest screenshot's - // coordinate space. Apply the same buffer-to-window ratio as - // click/drag so fractional-scale Wayland captures land on the - // intended logical surface point rather than below it. - let ratio = self.state.resize_registry.ratio(pid).unwrap_or(1.0); - Some((x * ratio, y * ratio)) - } - (None, None) => None, - _ => return ToolResult::error("Pass both x and y to pixel-target scroll."), - }; - if pixel_target.is_some() - && matches!( - &resolved, - cua_driver_core::element_token::ResolvedElement::Element { .. } - ) - { - return ToolResult::error( - "Pass either element_index (ax) or x,y (px) to scroll, not both.", - ); - } - if crate::wayland::is_inject_mode() { let Some((x, y)) = pixel_target else { return crate::input::delivery::background_unavailable_error( @@ -4688,11 +4865,7 @@ impl Tool for DoubleClickTool { cursor_id.clone(), cursor_overlay::OverlayCommand::PinAbove(xid), ); - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; } let lxi = lx as i32; let lyi = ly as i32; @@ -4779,11 +4952,7 @@ impl Tool for DoubleClickTool { .and_then(|r| r.ok()) }; if let Some((sx, sy)) = glide_target { - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; } let (xi, yi) = (x as i32, y as i32); let cursor_id_for_task = cursor_id.clone(); @@ -4927,11 +5096,7 @@ impl Tool for RightClickTool { cursor_id.clone(), cursor_overlay::OverlayCommand::PinAbove(xid), ); - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; } let lxi = lx as i32; let lyi = ly as i32; @@ -5018,11 +5183,7 @@ impl Tool for RightClickTool { .and_then(|r| r.ok()) }; if let Some((sx, sy)) = glide_target { - overlay_glide_to_for(&cursor_id, sx, sy).await; - crate::overlay::send_command_for( - cursor_id.clone(), - cursor_overlay::OverlayCommand::ClickPulse { x: sx, y: sy }, - ); + reveal_pointer_action_for(&self.state, &cursor_id, sx, sy, true).await; } let (xi, yi) = (x as i32, y as i32); let cursor_id_for_task = cursor_id.clone(); @@ -8128,7 +8289,12 @@ pub fn build_registry_with_provider( }, &pid_window_candidates, )); - r.register(pid_window_guarded(SetValueTool, &pid_window_candidates)); + r.register(pid_window_guarded( + SetValueTool { + state: state.clone(), + }, + &pid_window_candidates, + )); r.register(pid_window_guarded( ScrollTool { state: state.clone(), @@ -8307,6 +8473,77 @@ mod pid_window_target_tests { } } +#[cfg(test)] +mod session_cursor_target_tests { + use super::{ + choose_keyboard_cursor_target, named_session_cursor_key, reveal_pointer_action_for, + ToolState, + }; + use serde_json::json; + + #[test] + fn lifecycle_owned_sessions_opt_into_keyboard_cursor_positioning() { + assert_eq!( + named_session_cursor_key(&json!({"session": "editing-run"})).as_deref(), + Some("editing-run") + ); + assert_eq!( + named_session_cursor_key(&json!({"cursor_id": "legacy"})), + None + ); + assert_eq!( + named_session_cursor_key(&json!({"_session_id": "implicit"})).as_deref(), + Some("implicit") + ); + assert_eq!(named_session_cursor_key(&json!({})), None); + } + + #[test] + fn keyboard_cursor_uses_explicit_then_remembered_then_safe_seed() { + let explicit = Some((10.0, 20.0)); + let remembered = Some((30.0, 40.0)); + let window_center = Some((50.0, 60.0)); + let current_pointer = Some((70.0, 80.0)); + + assert_eq!( + choose_keyboard_cursor_target(explicit, remembered, window_center, current_pointer), + explicit + ); + assert_eq!( + choose_keyboard_cursor_target(None, remembered, window_center, current_pointer), + remembered + ); + assert_eq!( + choose_keyboard_cursor_target(None, None, window_center, current_pointer), + window_center + ); + assert_eq!( + choose_keyboard_cursor_target(None, None, None, current_pointer), + current_pointer + ); + } + + #[test] + fn invalid_coordinates_do_not_poison_session_position_reuse() { + assert_eq!( + choose_keyboard_cursor_target(Some((f64::NAN, 1.0)), Some((12.0, 34.0)), None, None,), + Some((12.0, 34.0)) + ); + } + + #[tokio::test] + async fn pointer_position_survives_an_unavailable_overlay() { + let state = ToolState::new(); + reveal_pointer_action_for(&state, "no-renderer", 123.0, 456.0, true).await; + + let cursor = state + .cursor_registry + .get("no-renderer") + .expect("pointer action records its position independently of rendering"); + assert_eq!(cursor.x.zip(cursor.y), Some((123.0, 456.0))); + } +} + #[cfg(test)] mod desktop_capture_frame_tests { use super::normalize_desktop_capture_for_action_frame; From 34f2286120b6655453ccc27acaae643aa1b812df Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 16 Aug 2026 21:55:58 -0500 Subject: [PATCH 114/117] fix(cua-driver): preserve user pointer in agent mode --- .../rust/Skills/cua-driver/LINUX.md | 18 ++++- .../rust/Skills/cua-driver/SKILL.md | 48 ++++++----- .../rust/Skills/cua-driver/WINDOWS.md | 10 +-- .../rust/crates/cua-driver/src/skills.rs | 32 ++++++++ .../crates/platform-linux/src/tools/impl_.rs | 79 +++++++++++-------- 5 files changed, 124 insertions(+), 63 deletions(-) diff --git a/libs/cua-driver/rust/Skills/cua-driver/LINUX.md b/libs/cua-driver/rust/Skills/cua-driver/LINUX.md index 686a5b5dde..c6d0cb65bc 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/LINUX.md +++ b/libs/cua-driver/rust/Skills/cua-driver/LINUX.md @@ -34,8 +34,11 @@ AT-SPI is talked to natively over D-Bus (the `atspi`/zbus crate) — no clicked" case. - The **agent cursor** is a synthetic overlay showing where the run is acting; it never moves the real pointer (same model as macOS/Windows). - It glides on clicks and `move_cursor`; issue `move_cursor` to make it - track a field while typing advances focus across cells. + It glides on clicks and ordinary `move_cursor` calls; cursor-bearing and + keyboard actions re-show it automatically. Only the explicit + `move_cursor({x,y,scope:"desktop"})` escape hatch moves the compositor + cursor. Do not use desktop scope unless the user asked for real-pointer + control. ## `delivery_mode` — the background/foreground ladder @@ -55,6 +58,12 @@ and Windows surface: e.g. a GTK dialog button or a widget that only reads input while focused. A brief focus swap unless the target was already active. +Foreground is a user-visible takeover boundary. Never select it automatically. +Use it only when the user already authorized foreground control for the +workflow or after asking for approval. If background delivery refuses and that +authorization is absent, return the refusal instead of changing the user's +focus, workspace, or compositor cursor. + ### Persistent focus-proxy exception `bring_to_front` is not part of the normal input ladder. For an ordinary @@ -226,8 +235,9 @@ control. Other focus-bound background pointer and keyboard shapes return an exact `background_unavailable` result. They do not report success after a silent drop. -Use `delivery_mode:"foreground"` for raw Wayland input. The driver activates -the selected target through a verified compositor adapter before dispatch. If +Raw Wayland input requires explicitly authorized `delivery_mode:"foreground"`. +The driver activates the selected target through a verified compositor adapter +before dispatch. If the compositor has no target-addressable activation or input backend, the call refuses before sending input. Reconstructing coordinates alone does not make raw background PX possible on a standard compositor. diff --git a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md index 23908f7c8a..9eaabf0a33 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/SKILL.md +++ b/libs/cua-driver/rust/Skills/cua-driver/SKILL.md @@ -177,6 +177,13 @@ If you reach for a command that says "activate", "foreground", "raise", or "make key", stop and translate to the cua-driver tool that does the same intent without focus-stealing. +`delivery_mode:"foreground"` is a user-visible takeover boundary, not an +automatic retry. Use it only when the user already authorized foreground +control for this workflow or after asking for approval. It may change focus, +workspace, and the compositor cursor while the action runs; restoration is +best-effort. If background delivery is unavailable and foreground control is +not authorized, stop with the driver's refusal instead of silently escalating. + A desktop target is an explicit per-call choice to operate the visible desktop and therefore uses foreground/system input. Use it only after the narrower window ladder has been attempted and verified. Permission policy must still @@ -201,7 +208,7 @@ Every reference to `click(...)`, `get_window_state(...)` etc. in this skill means `cua-driver click '{...}'` — translate to MCP form only when MCP is requested. -### Claude Code computer-use compatibility mode +### Claude Code computer-use compatibility flag For normal Claude Code use, keep the default CLI or `cua-driver` MCP server path above. If the user explicitly wants Claude Code's @@ -211,20 +218,10 @@ vision/computer-use-style flow, they can register: cua-driver mcp-config --client claude # then paste + run the printed line ``` -Observation: Claude Code vision flows appear to treat a screenshot -MCP tool as the image-grounding anchor. This compatibility mode keeps -the normal CuaDriver tools and changes only `screenshot`. The -compatibility `screenshot` requires `pid` and `window_id`, captures -only that target window, and returns the window-local pixel -coordinate frame. Start with `launch_app` or `list_windows`, then -call `screenshot({pid, window_id})`; do not assume desktop -coordinates or a full-screen capture. - -Use MCP for this Claude Code vision/computer-use-style path. Do not -shell out to `cua-driver screenshot` as a substitute: CLI screenshots -still work as CuaDriver calls, but they do not expose the -`mcp__cua-computer-use__screenshot` tool name that Claude Code -appears to use as the image-grounding cue. +The compatibility flag is retained for old setup snippets, but the standalone +`screenshot` tool was removed. It does not add or replace tools. Use +`get_window_state({pid, window_id})` for a window-local accessibility snapshot +and PNG, or `get_desktop_state()` for an explicitly authorized desktop capture. ## Using cua-driver from the shell @@ -282,6 +279,15 @@ recording, and system activity. Motion knobs: `end_handle`, `arc_size`, `arc_flow`, `spring` — tuneable at runtime, persisted to config. +**Agent-control safety.** Keep this overlay enabled whenever the agent is +controlling pointer or keyboard input. Cursor-bearing and keyboard actions +automatically re-show their session cursor, even if it was hidden while idle. +On Linux, ordinary `move_cursor({x,y})` moves only this synthetic cursor. +`move_cursor({x,y,scope:"desktop"})` is the explicit escape hatch that moves +the user's compositor cursor and must not be used unless the user asked for +desktop-pointer control. Raw Wayland input that requires +`delivery_mode:"foreground"` crosses the same user-visible takeover boundary. + Delivery and target context is shown as host-owned chips inside the session badge. Themes own the twelve action animations only. The session name and context chips fade independently, so an active tool can show its execution @@ -632,6 +638,8 @@ if resp.effect == "suspected_noop" # Route 4 — background delivery was dropped (insert/click never arrived) if resp.escalation.target == "foreground" or the px action still did nothing: + require existing user authorization for visible foreground control + otherwise stop and ask; do not retry automatically re-call the same action with delivery_mode:"foreground" # on Wayland this is the ONLY escalation — px-bg can't target an # unfocused window there; see LINUX.md @@ -768,10 +776,10 @@ The response carries: ```bash # write to file — stdout stays readable (AX/UIA tree / summary only, no base64) -cua-driver get_window_state '{"pid":N,"window_id":W,"screenshot_out_file":"/tmp/shot.jpg"}' +cua-driver get_window_state '{"pid":N,"window_id":W,"screenshot_out_file":"/tmp/shot.png"}' # CLI --screenshot-out-file flag is equivalent -cua-driver get_window_state '{"pid":N,"window_id":W}' --screenshot-out-file /tmp/shot.jpg +cua-driver get_window_state '{"pid":N,"window_id":W}' --screenshot-out-file /tmp/shot.png ``` Pass `screenshot_out_file` when using `get_window_state` via CLI or @@ -892,12 +900,14 @@ Two consequences for callers: `scroll`, `type_text`, `press_key`, `hotkey` — uniformly. The `foreground` rung briefly fronts the target, acts, then restores the prior frontmost: the explicit last resort when a background attempt - didn't land. **`foreground` is a reaction, never a prediction.** Always + didn't land. **`foreground` is a reaction and a user-authorization gate, + never a prediction.** Always fire the `background` default first and let the driver tell you it can't (a `background_unavailable` error with `escalation.recommended == "foreground"`, or a successful action result with `escalation.target == "foreground"`) — or observe a confirmed no-op — - _before_ you escalate. + _before_ you consider escalation. Then use it only when the user already + authorized visible foreground control or after asking for approval. Do **not** reason "it's a GTK/Chromium/Electron app, so background will drop, so I'll front up-front": the toolkit lists in the tool schemas are the _driver's_ internal detectors, not a checklist for you to front diff --git a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md index e0526c7c91..504b5f427c 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md +++ b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md @@ -823,12 +823,10 @@ typed browser tools yet. materialized yet. Re-call `list_windows({pid: N})` after 500ms; for chronic cases, key off the app name in `list_windows({})` output. -- **JPEG screenshot has more compression than expected** — default - quality on the MCP screenshot compat path is 85; for raw - `cua-driver call screenshot`, defaults to PNG (no compression). - Pass `{format: "jpeg", quality: 70}` to opt into compressed - screenshots. The `max_image_dimension` config (default 2048) - downscales via Lanczos3 before encoding. +- **Need a screenshot file** — use `get_window_state` with + `screenshot_out_file`; the result is PNG. The removed standalone screenshot + compatibility inputs (`format` and `quality`) are rejected by the live tool + schema. Downsample or convert the saved PNG outside cua-driver if needed. ## Diagnostics diff --git a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs index e2f8d701e8..e335b5867e 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/skills.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/skills.rs @@ -1203,6 +1203,38 @@ mod tests { } } + #[test] + fn bundled_skill_keeps_agent_control_non_interfering_by_default() { + let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let skill = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/SKILL.md")) + .expect("canonical skill must be readable"); + let linux = std::fs::read_to_string(crate_dir.join("../../Skills/cua-driver/LINUX.md")) + .expect("canonical Linux skill must be readable"); + + for required in [ + "`delivery_mode:\"foreground\"` is a user-visible takeover boundary", + "ordinary `move_cursor({x,y})` moves only this synthetic cursor", + "must not be used unless the user asked for", + "do not retry automatically", + "stop with the driver's refusal instead of silently escalating", + ] { + assert!( + skill.contains(required), + "skill lost required agent-control safety guidance: {required}" + ); + } + for required in [ + "keyboard actions re-show it automatically", + "Never select it automatically", + "explicitly authorized `delivery_mode:\"foreground\"`", + ] { + assert!( + linux.contains(required), + "Linux skill lost required non-interference guidance: {required}" + ); + } + } + #[test] fn extract_flat_tarball_v_0_2_20_plus() { // Post-fix shape: one wrapper dir, files directly under it. diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index f066d31797..be59105592 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -2097,9 +2097,12 @@ fn overlay_move_to_for(cursor_id: &str, sx: f64, sy: f64, heading: Option) } async fn overlay_glide_to_for(cursor_id: &str, sx: f64, sy: f64) { - if !crate::overlay::is_enabled_for(cursor_id) { - return; - } + // Input always revives its agent cursor. Hiding it is useful while idle, + // but a hidden cursor must not make pointer or keyboard control invisible. + crate::overlay::send_command_for( + cursor_id.to_owned(), + cursor_overlay::OverlayCommand::SetEnabled(true), + ); // Wayland (Mutter/KDE, no layer-shell): glide the agent cursor via the // WinRects shell extension. It eases to the target itself, so send the // destination once here rather than the interpolated stream the X11 render @@ -2132,6 +2135,7 @@ async fn reveal_pointer_action_for( if !sx.is_finite() || !sy.is_finite() { return; } + state.cursor_registry.set_enabled(cursor_id, true); state.cursor_registry.update_position(cursor_id, sx, sy); overlay_glide_to_for(cursor_id, sx, sy).await; if click_pulse { @@ -2255,8 +2259,7 @@ async fn position_named_session_keyboard_cursor( cursor_overlay::OverlayCommand::PinAbove(xid), ); } - state.cursor_registry.update_position(&cursor_id, sx, sy); - overlay_glide_to_for(&cursor_id, sx, sy).await; + reveal_pointer_action_for(state, &cursor_id, sx, sy, false).await; } async fn track_overlay_drag_for( @@ -2266,9 +2269,10 @@ async fn track_overlay_drag_for( duration_ms: u64, steps: usize, ) { - if !crate::overlay::is_enabled_for(&cursor_id) { - return; - } + crate::overlay::send_command_for( + cursor_id.clone(), + cursor_overlay::OverlayCommand::SetEnabled(true), + ); crate::overlay::send_command_for( cursor_id.clone(), cursor_overlay::OverlayCommand::SetPressed(true), @@ -6792,12 +6796,25 @@ pub struct MoveCursorTool { static MCURSOR_DEF: std::sync::OnceLock = std::sync::OnceLock::new(); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CursorControlScope { + Agent, + Desktop, +} + +fn cursor_control_scope(args: &Value) -> CursorControlScope { + match args.get("scope").and_then(Value::as_str) { + Some("desktop") => CursorControlScope::Desktop, + _ => CursorControlScope::Agent, + } +} + #[async_trait] impl Tool for MoveCursorTool { fn def(&self) -> &ToolDef { MCURSOR_DEF.get_or_init(|| ToolDef { name: "move_cursor".into(), - description: "Move the agent cursor overlay, or with scope=desktop move the real OS pointer in get_desktop_state coordinates.".into(), + description: "Move the synthetic agent cursor without changing the user's pointer. Only an explicit scope=desktop request moves the real OS pointer in get_desktop_state coordinates.".into(), input_schema: json!({"type":"object","required":["x","y"],"properties":{ "x":{"type":"number"},"y":{"type":"number"},"session": cua_driver_core::tool_schema::session_schema(),"cursor_id":{"type":"string"},"scope":{"type":"string","enum":["window","desktop"],"default":"window"} },"additionalProperties":false}), @@ -6806,7 +6823,7 @@ impl Tool for MoveCursorTool { } async fn invoke(&self, args: Value) -> ToolResult { use cua_driver_core::tool_args::ArgsExt; - if args.opt_str("scope").as_deref() == Some("desktop") { + if cursor_control_scope(&args) == CursorControlScope::Desktop { let input = match parse_typed_projection::("move_cursor", &args) { Ok(input) => input, Err(result) => return result, @@ -6842,35 +6859,15 @@ impl Tool for MoveCursorTool { } let x = args.f64_or("x", 0.0); let y = args.f64_or("y", 0.0); - let window_id = args.get("window_id").and_then(|v| v.as_u64()); let cursor_id = resolve_cursor_key(&args); - self.state.cursor_registry.update_position(&cursor_id, x, y); // End pointing upper-left (45°) — matches Swift's // `AgentCursor.animateAndWait(endAngleDegrees: 45)` convention so the // overlay arrow settles to the natural macOS-style pose. // Use the acknowledged animation path so a first-ever move seeds and // displays the session cursor just as reliably as a coordinate click. - crate::overlay::animate_cursor_to_for(cursor_id.clone(), x, y).await; - // Native Wayland: also warp the real cursor via zwlr_virtual_pointer. - // Off-thread because the wayland-client roundtrip is blocking. Best-effort - // — overlay update + registry write already succeeded; surface a warning - // only if the warp itself failed. - let real_warp_note = if crate::wayland::wayland_input_enabled() { - let xi = x.round() as i32; - let yi = y.round() as i32; - match tokio::task::spawn_blocking(move || { - crate::wayland::move_cursor_absolute(window_id, xi, yi) - }) - .await - { - Ok(Ok(())) => " (real cursor warped via virtual-pointer)", - Ok(Err(_)) | Err(_) => " (overlay updated; real-cursor warp failed)", - } - } else { - "" - }; + reveal_pointer_action_for(&self.state, &cursor_id, x, y, false).await; ToolResult::text(format!( - "Agent cursor '{cursor_id}' moved to ({x:.1}, {y:.1}).{real_warp_note}" + "Agent cursor '{cursor_id}' moved to ({x:.1}, {y:.1}); the user pointer was unchanged." )) } } @@ -8476,8 +8473,8 @@ mod pid_window_target_tests { #[cfg(test)] mod session_cursor_target_tests { use super::{ - choose_keyboard_cursor_target, named_session_cursor_key, reveal_pointer_action_for, - ToolState, + choose_keyboard_cursor_target, cursor_control_scope, named_session_cursor_key, + reveal_pointer_action_for, CursorControlScope, ToolState, }; use serde_json::json; @@ -8531,6 +8528,19 @@ mod session_cursor_target_tests { ); } + #[test] + fn real_pointer_control_requires_explicit_desktop_scope() { + assert_eq!(cursor_control_scope(&json!({})), CursorControlScope::Agent); + assert_eq!( + cursor_control_scope(&json!({"scope": "window"})), + CursorControlScope::Agent + ); + assert_eq!( + cursor_control_scope(&json!({"scope": "desktop"})), + CursorControlScope::Desktop + ); + } + #[tokio::test] async fn pointer_position_survives_an_unavailable_overlay() { let state = ToolState::new(); @@ -8540,6 +8550,7 @@ mod session_cursor_target_tests { .cursor_registry .get("no-renderer") .expect("pointer action records its position independently of rendering"); + assert!(cursor.config.enabled, "input must revive its agent cursor"); assert_eq!(cursor.x.zip(cursor.y), Some((123.0, 456.0))); } } From 1be6b39b816be7ed21c31418b1cc3eee4c6cc5d0 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 16 Aug 2026 22:36:31 -0500 Subject: [PATCH 115/117] fix(cua-driver): reconcile Wayland overlay state on v0.20 --- .../platform-linux/src/wayland/overlay.rs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs index 8ca35a3746..87a9f4ed88 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/overlay.rs @@ -346,8 +346,8 @@ fn apply_keyed_command( let core = cores .entry(key.clone()) .or_insert_with(|| render_core_for_key(template, &key)); - // Seed from the off-screen sentinel near the first targeted action so a - // spring animation begins on-screen. This mirrors the X11 renderer. + // Seed an unplaced cursor near the first targeted action so a spring + // animation begins on-screen. This mirrors the X11 renderer. let seed_target = match &cmd { OverlayCommand::MoveTo { x, y, .. } | OverlayCommand::SnapTo { x, y, .. } @@ -355,17 +355,17 @@ fn apply_keyed_command( _ => None, }; if let Some((target_x, target_y)) = seed_target { - if core.pos.0 < -50.0 { + if core.pos.is_none() { const SEED_OFFSET: f64 = 16.0; - core.pos = ( + core.pos = Some(( (target_x - SEED_OFFSET).max(2.0), (target_y - SEED_OFFSET).max(2.0), - ); + )); } } let disabling = matches!(&cmd, OverlayCommand::SetEnabled(false)); - let dirty = core.apply_command_base(cmd, false, false); + let dirty = core.apply_command_base(cmd, true); if disabling { quiesce_hidden(core); } @@ -458,10 +458,10 @@ fn visible_cores_for_output<'a>( .iter() .filter(|(_, core)| { core.visible - && core.pos.0 >= -100.0 && core.idle_alpha >= 0.004 - && select_output(layouts, core.pos.0, core.pos.1) - .is_some_and(|selected| selected.id == output_id) + && core.pos.is_some_and(|(x, y)| { + select_output(layouts, x, y).is_some_and(|selected| selected.id == output_id) + }) }) .collect(); visible_cores.sort_by(|(left, _), (right, _)| left.cmp(right)); @@ -818,8 +818,8 @@ fn redraw( let cursor_positions = state .cores .values() - .filter(|core| core.visible && core.pos.0 >= -100.0 && core.idle_alpha >= 0.004) - .map(|core| core.pos); + .filter(|core| core.visible && core.idle_alpha >= 0.004) + .filter_map(|core| core.pos); let (selected, targets) = frame_plan( &layouts, &state.painted_outputs, @@ -898,7 +898,9 @@ fn redraw_output( None, 1.0, ); - painted_positions.push(core.pos); + if let Some(position) = core.pos { + painted_positions.push(position); + } } } @@ -1618,7 +1620,7 @@ mod tests { &layouts, &HashSet::new(), &initialized(&layouts), - cores.values().map(|core| core.pos), + cores.values().filter_map(|core| core.pos), ); assert_eq!(painted, HashSet::from([1, 2])); assert_eq!(targets, vec![FrameTarget { id: 1 }, FrameTarget { id: 2 }]); @@ -1642,7 +1644,7 @@ mod tests { &layouts, &painted, &initialized(&layouts), - cores.values().map(|core| core.pos), + cores.values().filter_map(|core| core.pos), ); assert_eq!(selected, HashSet::from([2])); assert_eq!(targets, vec![FrameTarget { id: 1 }, FrameTarget { id: 2 }]); From ea707a5737b6606df68cf906d9abb14abfecb9c3 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 24 Aug 2026 23:24:14 -0500 Subject: [PATCH 116/117] fix(cua-driver): release held buttons during cleanup --- .../crates/platform-linux/src/tools/impl_.rs | 315 +++++++++++++++--- .../src/wayland/persistent_vptr.rs | 50 +++ 2 files changed, 324 insertions(+), 41 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index be59105592..3ed3c876e1 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -180,6 +180,7 @@ pub struct MouseHoldState { pub button: u8, pub x: f64, pub y: f64, + pub wayland: bool, } impl ToolState { @@ -2043,6 +2044,146 @@ fn mouse_hold_json(cursor_id: &str, hold: Option<&MouseHoldState>) -> Value { } } +struct MousePressCleanupGuard(Option>); + +impl MousePressCleanupGuard { + fn new(cleanup: impl FnOnce() + Send + 'static) -> Self { + Self(Some(Box::new(cleanup))) + } + + fn disarm(&mut self) { + self.0.take(); + } +} + +impl Drop for MousePressCleanupGuard { + fn drop(&mut self) { + if let Some(cleanup) = self.0.take() { + cleanup(); + } + } +} + +fn release_mouse_button(cursor_id: &str, hold: &MouseHoldState) -> anyhow::Result<()> { + if hold.wayland { + crate::wayland::persistent_vptr::release_all(cursor_id) + } else { + crate::input::send_button_up(hold.xid, hold.x as i32, hold.y as i32, hold.button) + } +} + +fn press_mouse_button_with_cleanup( + cursor_id: String, + hold: MouseHoldState, +) -> anyhow::Result { + if hold.wayland { + crate::wayland::persistent_vptr::press( + &cursor_id, + hold.xid, + hold.x as i32, + hold.y as i32, + hold.button, + )?; + } else { + crate::input::send_button_down(hold.xid, hold.x as i32, hold.y as i32, hold.button)?; + } + + let cleanup_cursor_id = cursor_id; + let cleanup_hold = hold; + Ok(MousePressCleanupGuard::new(move || { + if let Err(error) = release_mouse_button(&cleanup_cursor_id, &cleanup_hold) { + tracing::warn!( + cursor_id = %cleanup_cursor_id, + error = %error, + "failed to release a held pointer after mouse_button_down cancellation" + ); + if cleanup_hold.wayland { + if let Err(teardown_error) = + crate::wayland::persistent_vptr::forget(&cleanup_cursor_id) + { + tracing::warn!( + cursor_id = %cleanup_cursor_id, + error = %teardown_error, + "failed to tear down a cancelled persistent pointer" + ); + } + } + } + })) +} + +fn release_tracked_mouse_hold_with( + mouse_holds: &std::sync::Mutex>, + cursor_id: &str, + release: impl FnOnce(&MouseHoldState) -> Result<(), String>, +) -> Result { + let Some(hold) = mouse_holds.lock().unwrap().get(cursor_id).cloned() else { + return Ok(false); + }; + release(&hold)?; + mouse_holds.lock().unwrap().remove(cursor_id); + Ok(true) +} + +fn release_tracked_mouse_hold(state: &ToolState, cursor_id: &str) -> Result { + release_tracked_mouse_hold_with(&state.mouse_hold, cursor_id, |hold| { + release_mouse_button(cursor_id, hold).map_err(|error| error.to_string()) + }) +} + +fn cleanup_linux_pointer_session(state: &ToolState, cursor_id: &str) -> Result<(), String> { + if release_tracked_mouse_hold(state, cursor_id)? { + crate::overlay::send_command_for( + cursor_id.to_owned(), + cursor_overlay::OverlayCommand::SetPressed(false), + ); + } + state.cursor_registry.remove(cursor_id); + crate::overlay::remove_cursor(cursor_id.to_owned()); + crate::input::forget_master_pointer(cursor_id); + Ok(()) +} + +fn force_forget_linux_pointer_session(state: &ToolState, cursor_id: &str) { + if state + .mouse_hold + .lock() + .unwrap() + .get(cursor_id) + .is_some_and(|hold| hold.wayland) + { + let _ = crate::wayland::persistent_vptr::forget(cursor_id); + } + state.mouse_hold.lock().unwrap().remove(cursor_id); + crate::overlay::send_command_for( + cursor_id.to_owned(), + cursor_overlay::OverlayCommand::SetPressed(false), + ); + state.cursor_registry.remove(cursor_id); + crate::overlay::remove_cursor(cursor_id.to_owned()); + crate::input::forget_master_pointer(cursor_id); +} + +fn runtime_pointer_session_ids(state: &ToolState, prefix: &str) -> Vec { + let mut ids = state + .cursor_registry + .all_states() + .into_iter() + .map(|cursor| cursor.config.cursor_id) + .filter(|cursor_id| cursor_id.starts_with(prefix)) + .collect::>(); + ids.extend( + state + .mouse_hold + .lock() + .unwrap() + .keys() + .filter(|cursor_id| cursor_id.starts_with(prefix)) + .cloned(), + ); + ids.into_iter().collect() +} + fn held_target_mismatch( args: &Value, cursor_id: &str, @@ -5759,35 +5900,34 @@ impl Tool for MouseButtonDownTool { ); } - let xi = x as i32; - let yi = y as i32; // Native Wayland: route through the persistent virtual-pointer module // so the held button survives across tool calls; the X11 path keeps - // the existing input::send_button_down behaviour. - let result = if crate::wayland::is_wayland() { - let cid = cursor_id.clone(); - tokio::task::spawn_blocking(move || { - crate::wayland::persistent_vptr::press(&cid, xid, xi, yi, button) - }) - .await - } else { - tokio::task::spawn_blocking(move || crate::input::send_button_down(xid, xi, yi, button)) - .await + // the existing input::send_button_down behaviour. The cleanup guard + // is created inside the blocking task: if this async invocation is + // cancelled before it records the hold below, dropping the abandoned + // task result emits the matching release. + let hold = MouseHoldState { + pid, + xid, + button, + x, + y, + wayland: crate::wayland::is_wayland(), }; + let press_cursor_id = cursor_id.clone(); + let press_hold = hold.clone(); + let result = tokio::task::spawn_blocking(move || { + press_mouse_button_with_cleanup(press_cursor_id, press_hold) + }) + .await; match result { - Ok(Ok(())) => { - let hold = MouseHoldState { - pid, - xid, - button, - x, - y, - }; + Ok(Ok(mut cancellation_cleanup)) => { self.state .mouse_hold .lock() .unwrap() .insert(cursor_id.clone(), hold.clone()); + cancellation_cleanup.disarm(); if let Ok(Ok((sx, sy))) = tokio::task::spawn_blocking(move || window_local_to_screen(xid, x, y)).await { @@ -5924,7 +6064,7 @@ impl Tool for MouseDragTool { let mut result: anyhow::Result<()> = Ok(()); let mut prev_x = from_x; let mut prev_y = from_y; - let is_wl = crate::wayland::is_wayland(); + let is_wl = hold.wayland; for i in 1..=steps { let t = i as f64 / steps as f64; let ix = from_x + (to_x - from_x) * t; @@ -6113,7 +6253,7 @@ impl Tool for MouseButtonUpTool { // Native Wayland: release through the persistent virtual-pointer so // the same vptr device that emitted the press also emits the release // (single logical drag rather than a click pair). - let result = if crate::wayland::is_wayland() { + let result = if hold.wayland { let cid = cursor_id.clone(); tokio::task::spawn_blocking(move || { crate::wayland::persistent_vptr::release(&cid, button) @@ -8167,18 +8307,11 @@ pub fn build_registry_with_provider( )) }; let session_end_hook = { - let cursor_registry = state.cursor_registry.clone(); let state_for_session_end = state.clone(); - cua_driver_core::session::register_scoped_session_end_hook(move |session_id| { - cursor_registry.remove(session_id); - crate::overlay::remove_cursor(session_id.to_owned()); - state_for_session_end - .mouse_hold - .lock() - .unwrap() - .remove(session_id); - crate::input::forget_master_pointer(session_id); - }) + cua_driver_core::session::register_scoped_fallible_session_end_hook( + "linux_pointer_state", + move |session_id| cleanup_linux_pointer_session(&state_for_session_end, session_id), + ) }; let session_revive_hook = cua_driver_core::session::register_scoped_session_revive_hook(move |session_id| { @@ -8190,15 +8323,17 @@ pub fn build_registry_with_provider( r.retain_session_revive_hook(session_revive_hook); if let Some(runtime_scope) = cua_driver_core::tool::current_dispatch_runtime_scope() { let prefix = format!("__cua_runtime_{runtime_scope}:"); - let cursor_registry = state.cursor_registry.clone(); + let state_for_runtime = state.clone(); r.retain_runtime_cleanup(move || { - for cursor in cursor_registry - .all_states() - .into_iter() - .filter(|cursor| cursor.config.cursor_id.starts_with(&prefix)) - { - cursor_registry.remove(&cursor.config.cursor_id); - crate::overlay::remove_cursor(cursor.config.cursor_id); + for cursor_id in runtime_pointer_session_ids(&state_for_runtime, &prefix) { + if let Err(error) = cleanup_linux_pointer_session(&state_for_runtime, &cursor_id) { + tracing::warn!( + cursor_id, + error, + "failed to release held pointer during Linux runtime cleanup; forcing device teardown" + ); + force_forget_linux_pointer_session(&state_for_runtime, &cursor_id); + } } }); } @@ -8586,3 +8721,101 @@ mod desktop_capture_frame_tests { assert!(error.to_string().contains("cannot be mapped uniformly")); } } + +#[cfg(test)] +mod mouse_hold_cleanup_tests { + use super::{ + release_tracked_mouse_hold_with, runtime_pointer_session_ids, MouseHoldState, + MousePressCleanupGuard, ToolState, + }; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + fn hold(button: u8) -> MouseHoldState { + MouseHoldState { + pid: 42, + xid: 77, + button, + x: 12.0, + y: 34.0, + wayland: false, + } + } + + #[test] + fn abandoned_press_result_releases_while_an_adopted_result_disarms() { + let releases = Arc::new(AtomicUsize::new(0)); + let releases_for_abandoned = releases.clone(); + { + let _guard = MousePressCleanupGuard::new(move || { + releases_for_abandoned.fetch_add(1, Ordering::SeqCst); + }); + } + assert_eq!(releases.load(Ordering::SeqCst), 1); + + let releases_for_adopted = releases.clone(); + let mut guard = MousePressCleanupGuard::new(move || { + releases_for_adopted.fetch_add(1, Ordering::SeqCst); + }); + guard.disarm(); + drop(guard); + assert_eq!(releases.load(Ordering::SeqCst), 1); + } + + #[test] + fn successful_cleanup_releases_before_forgetting_the_hold() { + let holds = Mutex::new(HashMap::from([("session-a".to_owned(), hold(3))])); + let observed = Arc::new(Mutex::new(Vec::new())); + let observed_for_release = observed.clone(); + + assert_eq!( + release_tracked_mouse_hold_with(&holds, "session-a", move |held| { + observed_for_release + .lock() + .unwrap() + .push((held.xid, held.button, held.x, held.y)); + Ok(()) + }), + Ok(true) + ); + assert_eq!(*observed.lock().unwrap(), vec![(77, 3, 12.0, 34.0)]); + assert!(holds.lock().unwrap().is_empty()); + } + + #[test] + fn failed_cleanup_retains_the_hold_for_a_bounded_retry() { + let holds = Mutex::new(HashMap::from([("session-a".to_owned(), hold(1))])); + + assert_eq!( + release_tracked_mouse_hold_with(&holds, "session-a", |_| { + Err("temporary release failure".to_owned()) + }), + Err("temporary release failure".to_owned()) + ); + assert_eq!(holds.lock().unwrap()["session-a"].button, 1); + assert_eq!( + release_tracked_mouse_hold_with(&holds, "session-a", |_| Ok(())), + Ok(true) + ); + assert!(holds.lock().unwrap().is_empty()); + } + + #[test] + fn runtime_cleanup_finds_hold_only_sessions_in_its_own_namespace() { + let state = ToolState::new(); + state.mouse_hold.lock().unwrap().extend([ + ("__cua_runtime_scope-a:beta".to_owned(), hold(1)), + ("__cua_runtime_scope-a:alpha".to_owned(), hold(2)), + ("__cua_runtime_scope-b:other".to_owned(), hold(3)), + ]); + + assert_eq!( + runtime_pointer_session_ids(&state, "__cua_runtime_scope-a:"), + vec![ + "__cua_runtime_scope-a:alpha".to_owned(), + "__cua_runtime_scope-a:beta".to_owned(), + ] + ); + } +} diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs index 9ee2718e12..95f0a730c9 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs @@ -23,6 +23,8 @@ //! activate — would steal focus mid-drag) and roundtrip. //! - `release` emits a button release, removes from the held set; if the //! set is empty the vptr is destroyed and the map entry dropped. +//! - `release_all` emits releases for every button still held by one cursor; +//! session and runtime cleanup use this idempotent path before teardown. //! - On `Connection` roundtrip failure (compositor restart / disconnect) //! the owner thread tears down its connection and accepts the next //! command on a fresh one, emitting a typed error for the in-flight call. @@ -58,6 +60,10 @@ enum Cmd { button: u8, reply: Sender>, }, + ReleaseAll { + cursor_id: String, + reply: Sender>, + }, /// Drop the entry for a cursor_id without sending wire events — used to /// recover when the compositor disconnected mid-life. Forget { @@ -124,10 +130,15 @@ fn owner_thread(rx: Receiver) { let r = handle_release(&mut active, &cursor_id, button); let _ = reply.send(r); } + Cmd::ReleaseAll { cursor_id, reply } => { + let r = handle_release_all(&mut active, &cursor_id); + let _ = reply.send(r); + } Cmd::Forget { cursor_id, reply } => { if let Some(p) = active.remove(&cursor_id) { p.vptr.destroy(); } + forget_conn(&cursor_id); let _ = reply.send(Ok(())); } } @@ -230,6 +241,31 @@ fn handle_release( Ok(()) } +fn handle_release_all( + active: &mut HashMap, + cursor_id: &str, +) -> anyhow::Result<()> { + let Some(entry) = active.get_mut(cursor_id) else { + // Cleanup is intentionally idempotent: an absent entry means there is + // no live virtual-pointer device left that could keep a button held. + forget_conn(cursor_id); + return Ok(()); + }; + + for button in entry.held.iter().copied() { + entry.vptr.button(0, button, ButtonState::Released); + } + entry.vptr.frame(); + roundtrip_on_persistent(cursor_id)?; + + if let Some(pointer) = active.remove(cursor_id) { + pointer.vptr.destroy(); + roundtrip_on_persistent(cursor_id).ok(); + } + forget_conn(cursor_id); + Ok(()) +} + // Process-static slots for Connection + EventQueue keyed by cursor_id. The // EventQueue is !Send but we only touch these on the owner thread, so wrap // in a thread-local-by-construction pattern: store inside the same map so @@ -320,6 +356,20 @@ pub fn release(cursor_id: &str, button: u8) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("reply channel closed: {e}"))? } +/// Release every button still held by `cursor_id`, then destroy its persistent +/// virtual-pointer. An already-absent cursor succeeds so lifecycle cleanup can +/// be retried safely after a partial teardown. +pub fn release_all(cursor_id: &str) -> anyhow::Result<()> { + let (tx_r, rx_r) = bounded(1); + tx().send(Cmd::ReleaseAll { + cursor_id: cursor_id.to_string(), + reply: tx_r, + }) + .map_err(|e| anyhow::anyhow!("cua-persistent-vptr thread is dead: {e}"))?; + rx_r.recv() + .map_err(|e| anyhow::anyhow!("reply channel closed: {e}"))? +} + /// Drop the entry for `cursor_id` without emitting any Wayland events. /// Useful for recovery — if the agent thinks a button is held but the /// compositor disagrees, this clears the local state without trying to From c8a951c4f3c5750b5c68e0b5d2e8a9ee549df9c8 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 25 Aug 2026 01:41:18 -0500 Subject: [PATCH 117/117] fix(cua-driver): address Hyprland review findings Fail closed on unsupported WebKitGTK pointer delivery, bound single-element AT-SPI lookups, share Hyprland correlation refreshes, add guarded foreground focus leases, and normalize persistent pointer coordinates. --- .../crates/platform-linux/src/atspi/native.rs | 287 +++++---- .../platform-linux/src/browser_consent_ui.rs | 8 +- .../platform-linux/src/browser_setup_ui.rs | 10 +- .../crates/platform-linux/src/tools/impl_.rs | 61 +- .../platform-linux/src/wayland/hyprland.rs | 553 +++++++++++++++++- .../crates/platform-linux/src/wayland/mod.rs | 159 ++++- .../src/wayland/persistent_vptr.rs | 127 +++- 7 files changed, 1017 insertions(+), 188 deletions(-) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs index 84a3cd6a94..933e82734c 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/atspi/native.rs @@ -2410,11 +2410,8 @@ pub fn get_element_bounds(pid: u32, idx: usize) -> Result<(i32, i32, u32, u32)> } else { entry_find_window_xid(pid).await.unwrap_or(0) }; - element_bounds_for_visited(&visited, pid, xid) + element_bound_for_visited(&visited, pid, xid, idx) .await - .into_iter() - .find(|(element_index, _, _, _, _)| *element_index == idx) - .map(|(_, x, y, width, height)| (x, y, width, height)) .ok_or_else(|| anyhow!("element {idx} exposes no usable Component bounds")) }, || { @@ -2611,7 +2608,9 @@ fn combine_wayland_content_offsets( /// Compositor decorations and toolkit document offsets are independent and /// therefore additive: choosing one or the other leaves WebKit controls one /// title bar away from the pixels shown to the caller. -async fn web_document_extent_for_visited(visited: &[Visited<'_>]) -> Option<(i32, i32, i32, i32)> { +async fn web_document_raw_extent_for_visited( + visited: &[Visited<'_>], +) -> Option<(i32, i32, i32, i32)> { let document = visited .iter() .filter(|node| node.has_component) @@ -2619,13 +2618,18 @@ async fn web_document_extent_for_visited(visited: &[Visited<'_>]) -> Option<(i32 .min_by_key(|node| node.depth)?; let proxies = call(document.acc.proxies()).await?.ok()?; let component = call(proxies.component()).await?.ok()?; - match call(component.get_extents(CoordType::Window)).await { - Some(Ok(extent @ (_, _, width, height))) if width > 0 && height > 0 => Some(extent), - _ => None, - } + call(component.get_extents(CoordType::Window)).await?.ok() } -async fn web_document_origin_for_visited(visited: &[Visited<'_>], pid: u32) -> Option<(i32, i32)> { +fn usable_document_extent(extent: Option<(i32, i32, i32, i32)>) -> Option<(i32, i32, i32, i32)> { + extent.filter(|(_, _, width, height)| *width > 0 && *height > 0) +} + +fn web_document_origin_from_extent( + visited: &[Visited<'_>], + pid: u32, + document_extent: Option<(i32, i32, i32, i32)>, +) -> Option<(i32, i32)> { if !crate::wayland::is_wayland() { return None; } @@ -2633,38 +2637,22 @@ async fn web_document_origin_for_visited(visited: &[Visited<'_>], pid: u32) -> O let compositor = sway_window .as_ref() .map(|window| (window.content_x, window.content_y)); - let document = visited - .iter() - .filter(|node| node.has_component) - .filter(|node| is_document_role(&node.role) || node.in_web_doc) - .min_by_key(|node| node.depth); - let document = if let Some(document) = document { - match call(document.acc.proxies()).await { - Some(Ok(proxies)) => match call(proxies.component()).await { - Some(Ok(component)) => match call(component.get_extents(CoordType::Window)).await { - Some(Ok((x, y, width, height))) if x >= 0 && y >= 0 => { - let inferred_top = match (compositor, sway_window.as_ref()) { - (Some((_, 0)), Some(window)) - if width > 0 - && height > 0 - && (i64::from(window.width) - i64::from(width)).abs() <= 4 - && i64::from(window.height) > i64::from(height) => - { - (i64::from(window.height) - i64::from(height)) - .min(i64::from(i32::MAX)) as i32 - } - _ => 0, - }; - Some((x, y.max(inferred_top))) - } - _ => None, - }, - _ => None, - }, - _ => None, + let document = match document_extent { + Some((x, y, width, height)) if x >= 0 && y >= 0 => { + let inferred_top = match (compositor, sway_window.as_ref()) { + (Some((_, 0)), Some(window)) + if width > 0 + && height > 0 + && (i64::from(window.width) - i64::from(width)).abs() <= 4 + && i64::from(window.height) > i64::from(height) => + { + (i64::from(window.height) - i64::from(height)).min(i64::from(i32::MAX)) as i32 + } + _ => 0, + }; + Some((x, y.max(inferred_top))) } - } else { - None + _ => None, }; let document_is_separate = visited.iter().any(|node| node.on_web_process_bus); let combined = combine_wayland_content_offsets(compositor, document, document_is_separate); @@ -2674,6 +2662,11 @@ async fn web_document_origin_for_visited(visited: &[Visited<'_>], pid: u32) -> O combined } +async fn web_document_origin_for_visited(visited: &[Visited<'_>], pid: u32) -> Option<(i32, i32)> { + let extent = web_document_raw_extent_for_visited(visited).await; + web_document_origin_from_extent(visited, pid, extent) +} + fn screen_extent_rebase( x11_origin: (i32, i32), accessible_frame_origin: (i32, i32), @@ -2767,25 +2760,22 @@ fn rebase_renderer_window_offset( offset } -/// Screen-coordinate bounds for the exact visited sequence rendered into the -/// current snapshot. Nodes without a usable Component interface, or whose -/// extents query fails/times out, are omitted rather than borrowing another -/// live traversal's ordinal. -/// -/// GTK4 caveat: GTK4's AT-SPI bridge returns `GetExtents(Screen)` as `(0,0)` -/// for every element (issue #1564 / the #1739 a11y rework), so a screen query -/// is useless. Instead we query `CoordType::Window` (which GTK4 *does* report -/// correctly, per-widget) and add a deterministic screen offset — the X11 -/// window origin plus the GTK4 CSD shadow inset from `_GTK_FRAME_EXTENTS` (see -/// [`window_to_screen_offset`]). For GTK3/Qt the inset is absent, so the -/// offset is just the X11 origin and the result matches the old screen path. -/// -/// Returns `(element_index, x, y, width, height)` tuples. -async fn element_bounds_for_visited( +struct BoundsCoordinateContext { + coord: CoordType, + offset: Option<(i32, i32)>, + offset_x: i32, + offset_y: i32, + wayland_scale: Option, + web_wayland_scale: Option, + web_document_origin: Option<(i32, i32)>, +} + +async fn bounds_coordinate_context( visited: &[Visited<'_>], pid: u32, xid: u64, -) -> Vec<(usize, i32, i32, u32, u32)> { + needs_web_context: bool, +) -> BoundsCoordinateContext { // Query WINDOW-relative extents and add a deterministic screen offset // (X11 window origin + GTK4 CSD inset). This fixes GTK4 — whose // CoordType::Screen reports every element at (0,0) — by using the @@ -2882,19 +2872,21 @@ async fn element_bounds_for_visited( // while the renderer subtree uses device pixels. Compare the document // frame independently so web descendants are normalized without scaling // already-correct native controls. + let document_extent = if needs_web_context { + web_document_raw_extent_for_visited(visited).await + } else { + None + }; let web_wayland_scale = if compositor_geometry.is_some() { - wayland_extent_scale( - web_document_extent_for_visited(visited).await, - compositor_geometry, - ) + wayland_extent_scale(usable_document_extent(document_extent), compositor_geometry) } else { None }; // Add compositor decorations and the embedded document origin for web // descendants only. Electron commonly contributes zero for both; WebKitGTK // under Sway needs the sum. - let web_document_origin = if offset.is_some() { - web_document_origin_for_visited(visited, pid).await + let web_document_origin = if offset.is_some() && needs_web_context { + web_document_origin_from_extent(visited, pid, document_extent) } else { None }; @@ -2908,7 +2900,97 @@ async fn element_bounds_for_visited( dlog!("element bounds: SCREEN coords + X11 frame rebase ({ox},{oy})"); } - let action_nodes: Vec<&Visited> = visited.iter().filter(|v| is_indexable(v)).collect(); + BoundsCoordinateContext { + coord, + offset, + offset_x, + offset_y, + wayland_scale, + web_wayland_scale, + web_document_origin, + } +} + +fn normalize_element_extent( + extent: (i32, i32, i32, i32), + in_web_doc: bool, + context: &BoundsCoordinateContext, +) -> Option<(i32, i32, u32, u32)> { + let (x, y, width, height) = extent; + // Unrealized widgets (e.g. items inside closed menus/popovers) report + // GetExtents as the i32::MIN sentinel and/or a degenerate 0x0 / 1x1 size. + if x == i32::MIN || y == i32::MIN || x < -16384 || y < -16384 || width <= 1 || height <= 1 { + return None; + } + let document = if in_web_doc { + context.web_document_origin.unwrap_or((0, 0)) + } else { + (0, 0) + }; + let scale = if in_web_doc { + context.web_wayland_scale.or(context.wayland_scale) + } else { + context.wayland_scale + }; + Some(match (scale, context.offset) { + (Some(scale), Some(offset)) => { + scale_wayland_extent(x, y, width, height, document, offset, scale) + } + _ => ( + x + context.offset_x + document.0, + y + context.offset_y + document.1, + width as u32, + height as u32, + ), + }) +} + +async fn bound_for_node( + node: &Visited<'_>, + context: &BoundsCoordinateContext, +) -> Option<(i32, i32, u32, u32)> { + if !node.has_component { + return None; + } + let proxies = call(node.acc.proxies()).await?.ok()?; + let component = call(proxies.component()).await?.ok()?; + let extent = call(component.get_extents(context.coord)).await?.ok()?; + normalize_element_extent(extent, node.in_web_doc, context) +} + +/// Resolve one indexed node's bounds without issuing Component.GetExtents for +/// every other indexed node. The tree walk is still required to preserve the +/// public snapshot index; only the requested node plus frame/document context +/// participates in coordinate normalization. +async fn element_bound_for_visited( + visited: &[Visited<'_>], + pid: u32, + xid: u64, + requested_index: usize, +) -> Option<(i32, i32, u32, u32)> { + let target = visited + .iter() + .filter(|node| is_indexable(node)) + .nth(requested_index)?; + let context = bounds_coordinate_context(visited, pid, xid, target.in_web_doc).await; + bound_for_node(target, &context).await +} + +/// Screen-coordinate bounds for the exact visited sequence rendered into the +/// current snapshot. Nodes without a usable Component interface, or whose +/// extents query fails/times out, are omitted rather than borrowing another +/// live traversal's ordinal. Full snapshots deliberately retain the historical +/// all-indexed-node behavior; single-element lookups use +/// [`element_bound_for_visited`] above. +async fn element_bounds_for_visited( + visited: &[Visited<'_>], + pid: u32, + xid: u64, +) -> Vec<(usize, i32, i32, u32, u32)> { + let action_nodes: Vec<&Visited> = visited.iter().filter(|node| is_indexable(node)).collect(); + let needs_web_context = action_nodes.iter().any(|node| node.in_web_doc); + let context = bounds_coordinate_context(visited, pid, xid, needs_web_context).await; + // Hard wall-clock budget for the whole collection: on pathological // trees individual D-Bus calls each burn up to CALL_TIMEOUT (geany's // unrealized nodes did exactly that). Return whatever was collected @@ -2925,49 +3007,7 @@ async fn element_bounds_for_visited( ); break; } - if !node.has_component { - continue; - } - let proxies = match call(node.acc.proxies()).await { - Some(Ok(p)) => p, - _ => continue, - }; - let comp = match call(proxies.component()).await { - Some(Ok(c)) => c, - _ => continue, - }; - if let Some(Ok((x, y, w, h))) = call(comp.get_extents(coord)).await { - // Unrealized widgets (e.g. items inside closed menus/popovers) - // report GetExtents as the i32::MIN sentinel and/or a degenerate - // 0x0 / 1x1 size. Emitting those poisons downstream consumers - // (overlay renderers, click targeting), so keep only elements - // with plausible on-screen geometry. (Validate the raw extents, - // before applying the screen offset, so the sentinel check still - // catches unrealized widgets.) - if x == i32::MIN || y == i32::MIN || x < -16384 || y < -16384 || w <= 1 || h <= 1 { - continue; - } - let (document_x, document_y) = if node.in_web_doc { - web_document_origin.unwrap_or((0, 0)) - } else { - (0, 0) - }; - let node_scale = if node.in_web_doc { - web_wayland_scale.or(wayland_scale) - } else { - wayland_scale - }; - let (screen_x, screen_y, width, height) = match (node_scale, offset) { - (Some(scale), Some(offset)) => { - scale_wayland_extent(x, y, w, h, (document_x, document_y), offset, scale) - } - _ => ( - x + offset_x + document_x, - y + offset_y + document_y, - w as u32, - h as u32, - ), - }; + if let Some((screen_x, screen_y, width, height)) = bound_for_node(node, &context).await { out.push((idx, screen_x, screen_y, width, height)); } } @@ -3083,11 +3123,11 @@ mod coord_tests { use super::{ activation_index, before_snapshot_deadline, combine_wayland_content_offsets, is_activation_action, is_enabled_state, is_indexable_capabilities, is_passive_role, - is_web_process_bus, prefer_authoritative_wayland_origin, rebase_renderer_window_offset, - scale_wayland_extent, screen_extent_rebase, select_click_target, wayland_extent_scale, - ApplicationSelection, + is_web_process_bus, normalize_element_extent, prefer_authoritative_wayland_origin, + rebase_renderer_window_offset, scale_wayland_extent, screen_extent_rebase, + select_click_target, wayland_extent_scale, ApplicationSelection, BoundsCoordinateContext, }; - use atspi::{State, StateSet}; + use atspi::{CoordType, State, StateSet}; use std::time::Duration; #[test] @@ -3301,6 +3341,31 @@ mod coord_tests { ); } + #[test] + fn single_and_snapshot_bounds_share_the_same_coordinate_normalization() { + let context = BoundsCoordinateContext { + coord: CoordType::Window, + offset: Some((100, 50)), + offset_x: 108, + offset_y: 79, + wayland_scale: None, + web_wayland_scale: None, + web_document_origin: Some((10, 20)), + }; + assert_eq!( + normalize_element_extent((5, 6, 20, 10), false, &context), + Some((113, 85, 20, 10)) + ); + assert_eq!( + normalize_element_extent((5, 6, 20, 10), true, &context), + Some((123, 105, 20, 10)) + ); + assert_eq!( + normalize_element_extent((i32::MIN, 6, 20, 10), false, &context), + None + ); + } + #[test] fn fractionally_scaled_wayland_extents_use_compositor_logical_geometry() { let scale = wayland_extent_scale(Some((0, 0, 1892, 2085)), Some((3207, 38, 1261, 1390))) diff --git a/libs/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs b/libs/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs index 100862b4ab..1a98eb6688 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/browser_consent_ui.rs @@ -165,13 +165,7 @@ fn with_target_foreground( body: impl FnOnce() -> anyhow::Result, ) -> anyhow::Result { if std::env::var_os("WAYLAND_DISPLAY").is_some() { - if let Some(window) = - crate::wayland::sway_ipc::window_for_id(window_id).filter(|window| window.pid == pid) - { - crate::wayland::sway_ipc::with_focused_container(window.id, body) - } else { - crate::wayland::shell_helper::with_focused_window(pid, window_id, body) - } + crate::wayland::with_target_foreground(pid, window_id, body) } else { crate::input::with_x11_foreground(window_id, 80, body) } diff --git a/libs/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs b/libs/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs index 237fbe57d4..be0328b8bd 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/browser_setup_ui.rs @@ -110,13 +110,7 @@ fn with_target_foreground( body: impl FnOnce() -> anyhow::Result, ) -> anyhow::Result { if std::env::var_os("WAYLAND_DISPLAY").is_some() { - if let Some(window) = - crate::wayland::sway_ipc::window_for_id(window_id).filter(|window| window.pid == pid) - { - crate::wayland::sway_ipc::with_focused_container(window.id, body) - } else { - crate::wayland::shell_helper::with_focused_window(pid, window_id, body) - } + crate::wayland::with_target_foreground(pid, window_id, body) } else { crate::input::with_x11_foreground(window_id, 80, body) } @@ -125,7 +119,7 @@ fn with_target_foreground( fn close_tab(pid: u32, window_id: u64) -> anyhow::Result<()> { with_target_foreground(pid, window_id, || { if std::env::var_os("WAYLAND_DISPLAY").is_some() { - crate::wayland::hotkey(window_id, &["ctrl".to_owned(), "w".to_owned()]) + crate::wayland::hotkey_focused(&["ctrl".to_owned(), "w".to_owned()]) } else { crate::input::send_key_xtest("w", &["ctrl"]) } diff --git a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs index 3ed3c876e1..84f9a87c62 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/tools/impl_.rs @@ -1780,19 +1780,27 @@ fn unavailable_webkit_background( } fn unavailable_webkit_hyprland_pointer(pid: u32) -> Option { - (is_webkitgtk_embedder(pid) - && crate::wayland::hyprland::is_session() - && !crate::wayland::is_inject_mode()) - .then(|| { - ToolResult::error( - "Foreground pointer delivery is unavailable: WebKitGTK ignores Hyprland's virtual-pointer button events. Use an element-addressed left click when possible; right-click, double-click, and drag require a target-local compositor input backend.", - ) - .with_structured(json!({ - "code": "foreground_unavailable", - "reason": "webkitgtk_hyprland_virtual_pointer_buttons", - "delivery_mode": "foreground" - })) - }) + webkit_hyprland_pointer_must_refuse( + is_webkitgtk_embedder(pid), + crate::wayland::hyprland::is_session(), + crate::wayland::is_inject_mode(), + ) + .then(webkit_hyprland_pointer_refusal) +} + +fn webkit_hyprland_pointer_must_refuse(webkitgtk: bool, hyprland: bool, inject_mode: bool) -> bool { + webkitgtk && hyprland && !inject_mode +} + +fn webkit_hyprland_pointer_refusal() -> ToolResult { + ToolResult::error( + "Foreground pointer delivery is unavailable: WebKitGTK ignores Hyprland's virtual-pointer button events. Use an element-addressed left click when possible; right-click, double-click, and drag require a target-local compositor input backend.", + ) + .with_structured(json!({ + "code": "foreground_unavailable", + "reason": "webkitgtk_hyprland_virtual_pointer_buttons", + "delivery_mode": "foreground" + })) } fn unavailable_webkit_keyboard_background( @@ -2950,6 +2958,19 @@ impl Tool for ClickTool { return Ok("wayland_atspi"); } } + // WebKitGTK ignores Hyprland virtual-pointer button events. + // A successful single AT-SPI action returned above; every + // remaining foreground pixel shape must refuse rather than + // falling through to a no-op route and reporting success. + if delivery.is_foreground() + && webkit_hyprland_pointer_must_refuse( + webkitgtk, + crate::wayland::hyprland::is_session(), + crate::wayland::is_inject_mode(), + ) + { + return Ok("webkit_hyprland_foreground_unavailable"); + } if crate::wayland::is_inject_mode() { crate::wayland::inject_click(pid, xid, x, y, count as u32, button)?; return Ok("wayland_cua_compositor"); @@ -3033,6 +3054,7 @@ impl Tool for ClickTool { "background" }; match result { + Ok(Ok("webkit_hyprland_foreground_unavailable")) => webkit_hyprland_pointer_refusal(), Ok(Ok("background_unavailable")) => { crate::input::delivery::background_unavailable_error( crate::input::delivery::BackgroundUnavailable::FocusedInputOnly, @@ -8497,7 +8519,10 @@ pub fn build_registry_with_provider( #[cfg(test)] mod click_button_schema_tests { - use super::{chromium_background_must_refuse, maps_indicate_gtk, ClickTool}; + use super::{ + chromium_background_must_refuse, maps_indicate_gtk, webkit_hyprland_pointer_must_refuse, + ClickTool, + }; use cua_driver_core::tool::Tool; /// Surface 5: schema must advertise the three canonical button values and @@ -8542,6 +8567,14 @@ mod click_button_schema_tests { assert!(!chromium_background_must_refuse(false, false, false)); } + #[test] + fn webkit_hyprland_pointer_fallback_refuses_without_target_local_injection() { + assert!(webkit_hyprland_pointer_must_refuse(true, true, false)); + assert!(!webkit_hyprland_pointer_must_refuse(false, true, false)); + assert!(!webkit_hyprland_pointer_must_refuse(true, false, false)); + assert!(!webkit_hyprland_pointer_must_refuse(true, true, true)); + } + #[test] fn gtk_process_maps_are_detected_without_matching_unrelated_libraries() { assert!(maps_indicate_gtk( diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs index 6019f70df6..e275e71f0b 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/hyprland.rs @@ -9,8 +9,13 @@ use anyhow::{bail, Context, Result}; use serde::de::DeserializeOwned; use serde::Deserialize; -use std::collections::HashSet; -use std::process::Command; +use std::collections::{HashMap, HashSet}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const FOCUS_TIMEOUT: Duration = Duration::from_millis(500); +const FOCUS_POLL_INTERVAL: Duration = Duration::from_millis(15); #[derive(Clone, Debug, Default, Deserialize)] struct Workspace { @@ -29,12 +34,194 @@ struct Client { title: String, #[serde(default)] class: String, + #[serde(default, rename = "initialClass")] + initial_class: String, #[serde(default)] at: [i32; 2], #[serde(default)] size: [i32; 2], #[serde(default)] workspace: Workspace, + #[serde(default, rename = "initialTitle")] + initial_title: String, + #[serde(default, rename = "stableId")] + stable_id: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct ActiveWindow { + #[serde(default)] + address: String, + #[serde(default)] + pid: Option, + #[serde(default)] + class: String, + #[serde(default, rename = "initialClass")] + initial_class: String, + #[serde(default, rename = "initialTitle")] + initial_title: String, + #[serde(default, rename = "stableId")] + stable_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WindowIdentity { + address: u64, + stable_id: String, + pid: u32, + initial_class: String, + initial_title: String, +} + +impl WindowIdentity { + fn from_client(client: &Client) -> Option { + Some(Self { + address: parse_address(&client.address)?, + stable_id: client.stable_id.clone(), + pid: u32::try_from(client.pid).ok().filter(|pid| *pid != 0)?, + initial_class: if client.initial_class.is_empty() { + client.class.clone() + } else { + client.initial_class.clone() + }, + initial_title: client.initial_title.clone(), + }) + } +} + +impl ActiveWindow { + fn identity(&self) -> Option { + Some(WindowIdentity { + address: parse_address(&self.address)?, + stable_id: self.stable_id.clone(), + pid: self.pid.filter(|pid| *pid != 0)?, + initial_class: if self.initial_class.is_empty() { + self.class.clone() + } else { + self.initial_class.clone() + }, + initial_title: self.initial_title.clone(), + }) + } +} + +#[derive(Clone, Debug)] +struct FocusLease { + target: WindowIdentity, + prior: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum FocusRestoreDecision { + Restore(WindowIdentity), + NoPriorFocus, + ActiveFocusChanged, + PriorWindowUnavailable, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum WindowIdentityTrust { + Trusted(WindowIdentity), + RebindPending(WindowIdentity), + Missing(WindowIdentity), + ConflictingSnapshot, +} + +#[derive(Default)] +struct TrustedWindowIdentities { + by_address: HashMap, +} + +impl TrustedWindowIdentities { + fn observe_snapshot(&mut self, identities: &[WindowIdentity]) { + let mut snapshot = HashMap::>::new(); + for identity in identities { + snapshot + .entry(identity.address) + .and_modify(|observed| { + if observed.as_ref() != Some(identity) { + *observed = None; + } + }) + .or_insert_with(|| Some(identity.clone())); + } + + self.by_address.retain(|address, state| { + if snapshot.contains_key(address) { + return true; + } + let last_identity = match state { + WindowIdentityTrust::Trusted(identity) + | WindowIdentityTrust::RebindPending(identity) + | WindowIdentityTrust::Missing(identity) => Some(identity.clone()), + WindowIdentityTrust::ConflictingSnapshot => None, + }; + if let Some(identity) = last_identity { + *state = WindowIdentityTrust::Missing(identity); + true + } else { + false + } + }); + for (address, observed) in snapshot { + let Some(identity) = observed else { + self.by_address + .insert(address, WindowIdentityTrust::ConflictingSnapshot); + continue; + }; + let next = match self.by_address.get(&address) { + None => WindowIdentityTrust::Trusted(identity), + Some(WindowIdentityTrust::Trusted(current)) if current == &identity => { + WindowIdentityTrust::Trusted(identity) + } + Some(WindowIdentityTrust::RebindPending(current)) if current == &identity => { + WindowIdentityTrust::Trusted(identity) + } + Some(WindowIdentityTrust::Missing(current)) if current == &identity => { + WindowIdentityTrust::Trusted(identity) + } + Some(_) => WindowIdentityTrust::RebindPending(identity), + }; + self.by_address.insert(address, next); + } + } + + fn trusted(&self, address: u64, pid: u32) -> Result { + let identity = match self.by_address.get(&address) { + Some(WindowIdentityTrust::Trusted(identity)) => identity.clone(), + Some(WindowIdentityTrust::RebindPending(_)) => { + bail!( + "foreground_unavailable: Hyprland window address 0x{address:x} is pending identity rebind confirmation" + ) + } + Some(WindowIdentityTrust::Missing(_)) => { + bail!( + "foreground_unavailable: Hyprland window address 0x{address:x} is not currently live" + ) + } + Some(WindowIdentityTrust::ConflictingSnapshot) => { + bail!( + "foreground_unavailable: Hyprland reported conflicting identities for address 0x{address:x}" + ) + } + None => { + bail!( + "foreground_unavailable: unknown Hyprland window address 0x{address:x}; call list_windows first" + ) + } + }; + anyhow::ensure!( + identity.pid == pid, + "foreground_unavailable: Hyprland window 0x{address:x} belongs to pid {}, not pid {pid}", + identity.pid + ); + Ok(identity) + } +} + +fn trusted_window_identities() -> &'static Mutex { + static IDENTITIES: OnceLock> = OnceLock::new(); + IDENTITIES.get_or_init(|| Mutex::new(TrustedWindowIdentities::default())) } #[derive(Clone, Debug, Default, Deserialize)] @@ -121,7 +308,14 @@ pub fn list_windows() -> Result> { .map(|monitor| monitor.active_workspace.id) .filter(|workspace| *workspace != 0) .collect::>(); - Ok(windows_from_clients(&clients()?, &active_workspaces)) + let clients = clients()?; + { + let mut identities = trusted_window_identities() + .lock() + .map_err(|_| anyhow::anyhow!("Hyprland trusted-window registry is poisoned"))?; + identities.observe_snapshot(&live_identities(&clients)); + } + Ok(windows_from_clients(&clients, &active_workspaces)) } /// Return the logical bounding rectangle of all Hyprland outputs. Hyprland's @@ -318,6 +512,219 @@ pub fn window_for_app_id(app_id: &str) -> Option { } } +fn active_window() -> Result { + hyprctl_json("activewindow") +} + +fn live_identities(clients: &[Client]) -> Vec { + clients + .iter() + .filter_map(WindowIdentity::from_client) + .collect() +} + +fn verified_prior_identity( + active: Option, + target: &WindowIdentity, + live: &[WindowIdentity], +) -> Option { + active.filter(|identity| identity != target && live.iter().any(|item| item == identity)) +} + +fn focus_restore_decision( + lease: &FocusLease, + active: Option<&WindowIdentity>, + live: &[WindowIdentity], +) -> FocusRestoreDecision { + if active != Some(&lease.target) { + return FocusRestoreDecision::ActiveFocusChanged; + } + let Some(prior) = lease.prior.as_ref() else { + return FocusRestoreDecision::NoPriorFocus; + }; + match live + .iter() + .find(|identity| identity.address == prior.address) + { + Some(identity) if identity == prior => FocusRestoreDecision::Restore(prior.clone()), + _ => FocusRestoreDecision::PriorWindowUnavailable, + } +} + +fn target_client(pid: u32, window_id: u64) -> Result { + let expected = trusted_window_identities() + .lock() + .map_err(|_| anyhow::anyhow!("Hyprland trusted-window registry is poisoned"))? + .trusted(window_id, pid)?; + let clients = clients()?; + let target = clients + .into_iter() + .find(|client| { + WindowIdentity::from_client(client).as_ref() == Some(&expected) && client.mapped + }) + .ok_or_else(|| { + anyhow::anyhow!( + "foreground_unavailable: stale or reused Hyprland address 0x{window_id:x}" + ) + })?; + let active_workspaces = hyprctl_json::>("monitors")? + .into_iter() + .map(|monitor| monitor.active_workspace.id) + .filter(|workspace| *workspace != 0) + .collect::>(); + anyhow::ensure!( + !target.hidden && active_workspaces.contains(&target.workspace.id), + "foreground_unavailable: Hyprland target 0x{window_id:x} is not visible on an active workspace" + ); + Ok(target) +} + +fn focus_identity(identity: &WindowIdentity) -> Result<()> { + let current = live_identities(&clients()?) + .into_iter() + .find(|candidate| candidate.address == identity.address); + anyhow::ensure!( + current.as_ref() == Some(identity), + "refusing to focus stale or reused Hyprland address 0x{:x}", + identity.address + ); + + let selector = format!("address:0x{:x}", identity.address); + let lua_selector = serde_json::to_string(&selector)?; + let lua_dispatch = format!("hl.dsp.focus({{ window = {lua_selector} }})"); + let lua_output = Command::new(hyprctl_binary()) + .args(["dispatch", &lua_dispatch]) + .stdin(Stdio::null()) + .output() + .context("launch Hyprland Lua focus dispatch")?; + if !lua_output.status.success() || !lua_output.stdout.starts_with(b"ok") { + let legacy_output = Command::new(hyprctl_binary()) + .args(["dispatch", "focuswindow", &selector]) + .stdin(Stdio::null()) + .output() + .context("launch legacy Hyprland focuswindow dispatch")?; + if !legacy_output.status.success() || !legacy_output.stdout.starts_with(b"ok") { + bail!( + "Hyprland focus dispatch failed (Lua: {}; legacy: {})", + command_error(&lua_output), + command_error(&legacy_output) + ); + } + } + wait_for_focused_identity(identity) +} + +fn command_error(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if detail.is_empty() { + format!("exit status {}", output.status) + } else { + detail.to_owned() + } +} + +fn wait_for_focused_identity(identity: &WindowIdentity) -> Result<()> { + let deadline = Instant::now() + FOCUS_TIMEOUT; + loop { + if let Ok(active) = active_window() { + let active_identity = active.identity(); + if active_identity.as_ref() == Some(identity) { + return Ok(()); + } + if active_identity + .as_ref() + .is_some_and(|active| active.address == identity.address) + { + bail!( + "Hyprland address 0x{:x} was reused by a different window while focusing", + identity.address + ); + } + } + if Instant::now() >= deadline { + bail!( + "Hyprland did not focus window 0x{:x} within 500ms", + identity.address + ); + } + std::thread::sleep(FOCUS_POLL_INTERVAL); + } +} + +fn restore_temporary_focus(lease: &FocusLease) -> Result { + let active = active_window()?.identity(); + let live = live_identities(&clients()?); + let decision = focus_restore_decision(lease, active.as_ref(), &live); + if let FocusRestoreDecision::Restore(prior) = &decision { + // Revalidate the complete immutable identity immediately before + // dispatch. Hyprland addresses are allocator-derived and may be reused. + focus_identity(prior)?; + } + Ok(decision) +} + +fn begin_temporary_focus(target: Client) -> Result { + let target = WindowIdentity::from_client(&target) + .context("foreground_unavailable: Hyprland target has no valid identity")?; + let live = live_identities(&clients()?); + anyhow::ensure!( + live.iter().any(|identity| identity == &target), + "foreground_unavailable: Hyprland target changed identity before focus" + ); + let active = active_window()?; + let active_identity = active.identity(); + anyhow::ensure!( + active.address.trim().is_empty() || active_identity.is_some(), + "foreground_unavailable: Hyprland active window had no verifiable identity" + ); + anyhow::ensure!( + active_identity + .as_ref() + .is_none_or(|identity| identity == &target || live.iter().any(|item| item == identity)), + "foreground_unavailable: Hyprland active window identity was not live" + ); + let prior = verified_prior_identity(active_identity, &target, &live); + let lease = FocusLease { target, prior }; + if let Err(focus_error) = focus_identity(&lease.target) { + return match restore_temporary_focus(&lease) { + Ok(outcome) => Err(focus_error.context(format!( + "guarded rollback after failed Hyprland focus completed with {outcome:?}" + ))), + Err(rollback_error) => Err(focus_error.context(format!( + "guarded rollback after failed Hyprland focus also failed: {rollback_error}" + ))), + }; + } + Ok(lease) +} + +/// Run one global-keyboard transaction under an exact Hyprland focus lease. +/// The prior window is restored only while the target still owns focus, so a +/// user focus takeover is never overwritten. +pub fn with_focused_window( + pid: u32, + window_id: u64, + body: impl FnOnce() -> Result, +) -> Result { + let lease = begin_temporary_focus(target_client(pid, window_id)?)?; + let result = body(); + let restore = restore_temporary_focus(&lease); + match (result, restore) { + (Ok(value), Ok(_)) => Ok(value), + (Err(error), Ok(_)) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Err(error), Err(restore_error)) => Err(error.context(format!( + "the prior Hyprland focus also could not be restored: {restore_error}" + ))), + } +} + /// Resolve one exact Hyprland compositor address for a Wayland observation. /// Ambiguous title/app-id matches fail closed rather than selecting a sibling. pub fn resolve_capture_address( @@ -409,9 +816,22 @@ mod tests { pid, title: title.to_owned(), class: class.to_owned(), + initial_class: class.to_owned(), at: [10, 20], size: [800, 600], workspace: Workspace { id: 1 }, + initial_title: title.to_owned(), + stable_id: format!("stable-{address}"), + } + } + + fn identity(address: u64, stable_id: &str) -> WindowIdentity { + WindowIdentity { + address, + stable_id: stable_id.to_owned(), + pid: 42, + initial_class: "fixture".to_owned(), + initial_title: "Target".to_owned(), } } @@ -526,6 +946,133 @@ mod tests { assert_eq!(windows[0].width, 0); } + #[test] + fn trusted_identity_registry_trusts_initial_snapshot() { + let mut registry = TrustedWindowIdentities::default(); + registry.observe_snapshot(&[identity(0x1111, "first")]); + assert_eq!(registry.trusted(0x1111, 42).unwrap().stable_id, "first"); + } + + #[test] + fn trusted_identity_registry_guards_reuse_across_disappearance() { + let mut registry = TrustedWindowIdentities::default(); + registry.observe_snapshot(&[identity(0x1111, "first")]); + registry.observe_snapshot(&[]); + let error = registry + .trusted(0x1111, 42) + .expect_err("an absent address must remain unavailable"); + assert!(error.to_string().contains("not currently live")); + + registry.observe_snapshot(&[identity(0x1111, "replacement")]); + let error = registry + .trusted(0x1111, 42) + .expect_err("the first replacement after absence must remain untrusted"); + assert!(error + .to_string() + .contains("pending identity rebind confirmation")); + + registry.observe_snapshot(&[identity(0x1111, "replacement")]); + assert_eq!( + registry.trusted(0x1111, 42).unwrap().stable_id, + "replacement" + ); + } + + #[test] + fn trusted_identity_registry_recovers_same_identity_after_omission() { + let mut registry = TrustedWindowIdentities::default(); + registry.observe_snapshot(&[identity(0x1111, "first")]); + registry.observe_snapshot(&[]); + registry.observe_snapshot(&[identity(0x1111, "first")]); + + assert_eq!(registry.trusted(0x1111, 42).unwrap().stable_id, "first"); + } + + #[test] + fn trusted_identity_registry_recovers_after_confirmed_rebind() { + let mut registry = TrustedWindowIdentities::default(); + registry.observe_snapshot(&[identity(0x1111, "first")]); + + registry.observe_snapshot(&[identity(0x1111, "replacement")]); + let error = registry + .trusted(0x1111, 42) + .expect_err("the first recycled-address observation must remain untrusted"); + assert!(error + .to_string() + .contains("pending identity rebind confirmation")); + + registry.observe_snapshot(&[identity(0x1111, "replacement")]); + assert_eq!( + registry.trusted(0x1111, 42).unwrap().stable_id, + "replacement" + ); + } + + #[test] + fn trusted_identity_registry_restarts_grace_after_repeated_reuse() { + let mut registry = TrustedWindowIdentities::default(); + registry.observe_snapshot(&[identity(0x1111, "first")]); + registry.observe_snapshot(&[identity(0x1111, "second")]); + registry.observe_snapshot(&[identity(0x1111, "third")]); + + let error = registry + .trusted(0x1111, 42) + .expect_err("a different identity must restart pending confirmation"); + assert!(error + .to_string() + .contains("pending identity rebind confirmation")); + + registry.observe_snapshot(&[identity(0x1111, "third")]); + assert_eq!(registry.trusted(0x1111, 42).unwrap().stable_id, "third"); + + registry.observe_snapshot(&[identity(0x1111, "fourth")]); + assert!(registry.trusted(0x1111, 42).is_err()); + } + + #[test] + fn temporary_focus_restore_skips_user_focus_takeover() { + let target = identity(0x1111, "target"); + let prior = identity(0x2222, "prior"); + let user_target = identity(0x3333, "user"); + let lease = FocusLease { + target, + prior: Some(prior.clone()), + }; + assert_eq!( + focus_restore_decision(&lease, Some(&user_target), &[prior, user_target.clone()]), + FocusRestoreDecision::ActiveFocusChanged + ); + } + + #[test] + fn temporary_focus_restore_refuses_reused_prior_address() { + let target = identity(0x1111, "target"); + let prior = identity(0x2222, "prior"); + let replacement = identity(0x2222, "replacement"); + let lease = FocusLease { + target: target.clone(), + prior: Some(prior), + }; + assert_eq!( + focus_restore_decision(&lease, Some(&target), &[target.clone(), replacement]), + FocusRestoreDecision::PriorWindowUnavailable + ); + } + + #[test] + fn temporary_focus_restore_requires_target_to_still_own_focus() { + let target = identity(0x1111, "target"); + let prior = identity(0x2222, "prior"); + let lease = FocusLease { + target: target.clone(), + prior: Some(prior.clone()), + }; + assert_eq!( + focus_restore_decision(&lease, Some(&target), &[target.clone(), prior.clone()]), + FocusRestoreDecision::Restore(prior) + ); + } + #[test] fn output_layout_uses_logical_scaled_bounds() { let monitors = [ diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs index 8fa843a07f..1199f74d4f 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/mod.rs @@ -635,6 +635,39 @@ fn unique_hyprland_address( } } +fn correlate_hyprland_toplevels( + toplevels: &HashMap, + windows: &[hyprland::Window], +) -> HashMap { + let mut used = HashSet::new(); + let mut open = toplevels + .iter() + .filter(|(_, toplevel)| !toplevel.closed) + .collect::>(); + open.sort_unstable_by_key(|(id, _)| **id); + + open.into_iter() + .filter_map(|(id, toplevel)| { + let address = + unique_hyprland_address(windows, &used, &toplevel.title, &toplevel.app_id)?; + used.insert(address); + Some((*id, address)) + }) + .collect() +} + +fn hyprland_refresh_delay( + is_hyprland: bool, + toplevels: &HashMap, + correlations: &HashMap, +) -> Option { + let open_count = toplevels + .values() + .filter(|toplevel| !toplevel.closed) + .count(); + (is_hyprland && correlations.len() < open_count).then_some(std::time::Duration::from_millis(80)) +} + fn foreign_toplevel_state_is_activated(state: &[u8]) -> bool { state .chunks_exact(std::mem::size_of::()) @@ -665,7 +698,24 @@ pub fn list_windows() -> anyhow::Result> { } let mut hyprland_windows = hyprland::list_windows().unwrap_or_default(); - let mut used_hyprland_addresses = HashSet::new(); + let mut hyprland_correlations = + correlate_hyprland_toplevels(&state.toplevels, &hyprland_windows); + // A foreign-toplevel event can arrive a few milliseconds before the same + // just-mapped client appears in Hyprland IPC. Accommodate that race with + // one shared refresh for the whole enumeration, not a sleep + four IPC + // refreshes for every unmatched or ambiguous toplevel. + if let Some(delay) = hyprland_refresh_delay( + hyprland::is_session(), + &state.toplevels, + &hyprland_correlations, + ) { + std::thread::sleep(delay); + if let Ok(refreshed) = hyprland::list_windows() { + hyprland_windows = refreshed; + hyprland_correlations = + correlate_hyprland_toplevels(&state.toplevels, &hyprland_windows); + } + } let sway_windows = sway_ipc::list_windows().unwrap_or_default(); let mut used_sway_ids = HashSet::new(); let mut out = Vec::new(); @@ -678,39 +728,12 @@ pub fn list_windows() -> anyhow::Result> { } else { format!("{} [{}]", tl.title, tl.app_id) }; - let mut hyprland_address = unique_hyprland_address( - &hyprland_windows, - &used_hyprland_addresses, - &tl.title, - &tl.app_id, - ); - // A foreign-toplevel event can arrive a few milliseconds before the - // same just-mapped client appears in Hyprland IPC. Never expose the - // connection-scoped protocol object as if it were a stable window id; - // briefly retry the compositor-owned identity correlation instead. - if hyprland_address.is_none() && hyprland::is_session() { - for _ in 0..4 { - std::thread::sleep(std::time::Duration::from_millis(20)); - hyprland_windows = hyprland::list_windows().unwrap_or_default(); - hyprland_address = unique_hyprland_address( - &hyprland_windows, - &used_hyprland_addresses, - &tl.title, - &tl.app_id, - ); - if hyprland_address.is_some() { - break; - } - } - } + let hyprland_address = hyprland_correlations.get(id).copied(); let hyprland = hyprland_address.and_then(|address| { hyprland_windows .iter() .find(|window| window.address == address) }); - if let Some(window) = hyprland { - used_hyprland_addresses.insert(window.address); - } let sway = hyprland .is_none() @@ -1569,6 +1592,9 @@ pub fn with_target_foreground( window_id: u64, body: impl FnOnce() -> anyhow::Result, ) -> anyhow::Result { + if hyprland::is_session() { + return hyprland::with_focused_window(pid, window_id, body); + } if let Some(window) = sway_ipc::window_for_id(window_id) { if window.pid != pid { anyhow::bail!( @@ -3557,6 +3583,81 @@ mod tests { } } + fn toplevel(title: &str, app_id: &str) -> Toplevel { + Toplevel { + title: title.to_owned(), + app_id: app_id.to_owned(), + ..Toplevel::default() + } + } + + fn hypr_window(address: u64, title: &str, app_id: &str) -> hyprland::Window { + hyprland::Window { + address, + pid: 42, + title: title.to_owned(), + app_id: app_id.to_owned(), + x: 0, + y: 0, + width: 800, + height: 600, + workspace: 1, + visible: true, + } + } + + #[test] + fn hyprland_retry_plan_is_one_shared_refresh_for_any_unmatched_toplevel() { + let toplevels = HashMap::from([ + (1, toplevel("Ready", "ready.app")), + (2, toplevel("Just mapped", "new.app")), + ]); + let windows = [hypr_window(0x1111, "Ready", "ready.app")]; + let correlations = correlate_hyprland_toplevels(&toplevels, &windows); + + assert_eq!(correlations, HashMap::from([(1, 0x1111)])); + assert_eq!( + hyprland_refresh_delay(true, &toplevels, &correlations), + Some(std::time::Duration::from_millis(80)) + ); + assert_eq!( + hyprland_refresh_delay(false, &toplevels, &correlations), + None + ); + } + + #[test] + fn hyprland_correlation_remains_fail_closed_when_identity_is_ambiguous() { + let toplevels = HashMap::from([(1, toplevel("Shared", "shared.app"))]); + let windows = [ + hypr_window(0x1111, "Shared", "shared.app"), + hypr_window(0x2222, "Shared", "shared.app"), + ]; + let correlations = correlate_hyprland_toplevels(&toplevels, &windows); + + assert!(correlations.is_empty()); + assert!(hyprland_refresh_delay(true, &toplevels, &correlations).is_some()); + } + + #[test] + fn fully_correlated_hyprland_enumeration_skips_refresh() { + let toplevels = HashMap::from([ + (1, toplevel("First", "first.app")), + (2, toplevel("Second", "second.app")), + ]); + let windows = [ + hypr_window(0x1111, "First", "first.app"), + hypr_window(0x2222, "Second", "second.app"), + ]; + let correlations = correlate_hyprland_toplevels(&toplevels, &windows); + + assert_eq!(correlations.len(), 2); + assert_eq!( + hyprland_refresh_delay(true, &toplevels, &correlations), + None + ); + } + #[test] fn atspi_merge_keeps_x11_geometry_owner_and_native_only_frames() { let mut windows = vec![window(10, Some(100), "XWayland")]; diff --git a/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs b/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs index 95f0a730c9..0f4097523b 100644 --- a/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs +++ b/libs/cua-driver/rust/crates/platform-linux/src/wayland/persistent_vptr.rs @@ -78,9 +78,43 @@ struct ActivePointer { /// evdev codes of buttons currently held down. When this set becomes /// empty the vptr is destroyed and the entry dropped from the map. held: HashSet, - /// Output extent at session open time — needed for motion_absolute. + /// Target origin and output layout captured when the button went down. + /// Tool coordinates remain local to this held target for the whole drag. + target_x: i32, + target_y: i32, + out_x: i32, + out_y: i32, out_w: u32, out_h: u32, + hyprland: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TargetPointerPosition { + output_x: u32, + output_y: u32, + global_x: i32, + global_y: i32, +} + +fn target_pointer_position( + target_origin: (i32, i32), + output_layout: (i32, i32, u32, u32), + local_x: i32, + local_y: i32, +) -> TargetPointerPosition { + let global_x = target_origin.0.saturating_add(local_x); + let global_y = target_origin.1.saturating_add(local_y); + let output_x = (i64::from(global_x) - i64::from(output_layout.0)) + .clamp(0, i64::from(output_layout.2.saturating_sub(1))) as u32; + let output_y = (i64::from(global_y) - i64::from(output_layout.1)) + .clamp(0, i64::from(output_layout.3.saturating_sub(1))) as u32; + TargetPointerPosition { + output_x, + output_y, + global_x: output_layout.0.saturating_add_unsigned(output_x), + global_y: output_layout.1.saturating_add_unsigned(output_y), + } } /// Process-global command channel into the owner thread. Lazily started on @@ -153,19 +187,24 @@ fn handle_press( y: i32, button: u8, ) -> anyhow::Result<()> { + let (target_x, target_y, _, _) = super::window_geometry(window_id).ok_or_else(|| { + anyhow::anyhow!("no compositor geometry for held Wayland target window_id {window_id}") + })?; // Open a fresh session for this press — this binds the seat, the foreign- // toplevel manager, activates the target window, and creates a new vptr. // Keep the (out_w, out_h) but drop the queue + state at end of scope; the // vptr itself remains alive (Wayland objects survive their original queue // as long as the Connection is alive). let mut sess = open_vptr_session(Some(window_id))?; - let (w, h) = (sess.output_w, sess.output_h); - let px = x.clamp(0, w as i32 - 1) as u32; - let py = y.clamp(0, h as i32 - 1) as u32; + let (out_x, out_y, w, h) = (sess.output_x, sess.output_y, sess.output_w, sess.output_h); + let global_x = target_x.saturating_add(x); + let global_y = target_y.saturating_add(y); let btn = evdev_pointer_button(button); - sess.vptr.motion_absolute(0, px, py, w, h); - sess.vptr.frame(); + // Use the one-shot session's positioning path so output-origin + // normalization and Hyprland's compositor cursor correction stay exactly + // aligned with click/scroll/drag. + sess.position_pointer(global_x, global_y)?; sess.vptr.button(0, btn, ButtonState::Pressed); sess.vptr.frame(); sess.queue.roundtrip(&mut sess.state)?; @@ -187,8 +226,13 @@ fn handle_press( ActivePointer { vptr, held, + target_x, + target_y, + out_x, + out_y, out_w: w, out_h: h, + hyprland: super::hyprland::is_session(), }, ); Ok(()) @@ -205,13 +249,20 @@ fn handle_move( "no held mouse button for cursor '{cursor_id}'; call mouse_button_down first" ) })?; - let px = x.clamp(0, entry.out_w as i32 - 1) as u32; - let py = y.clamp(0, entry.out_h as i32 - 1) as u32; + let point = target_pointer_position( + (entry.target_x, entry.target_y), + (entry.out_x, entry.out_y, entry.out_w, entry.out_h), + x, + y, + ); entry .vptr - .motion_absolute(0, px, py, entry.out_w, entry.out_h); + .motion_absolute(0, point.output_x, point.output_y, entry.out_w, entry.out_h); entry.vptr.frame(); roundtrip_on_persistent(cursor_id)?; + if entry.hyprland { + super::hyprland::move_cursor(point.global_x, point.global_y)?; + } Ok(()) } @@ -307,11 +358,12 @@ fn roundtrip_on_persistent(cursor_id: &str) -> anyhow::Result<()> { // ── public API ──────────────────────────────────────────────────────────── -/// Press and HOLD `button` (evdev code) at output coordinates `(x, y)` on the -/// toplevel identified by `window_id`. Subsequent `move_to` / `release` calls -/// targeting the same `cursor_id` reuse the same virtual-pointer device, so -/// the compositor treats the sequence as one logical drag rather than as -/// independent clicks. Errors if `cursor_id` already has a held button. +/// Press and HOLD `button` (evdev code) at window-local coordinates `(x, y)` +/// on the toplevel identified by `window_id`. Subsequent `move_to` calls use +/// the same target-local coordinate space, while `release` reuses the same +/// virtual-pointer device so the compositor treats the sequence as one +/// logical drag rather than independent clicks. Errors if `cursor_id` already +/// has a held button. pub fn press(cursor_id: &str, window_id: u64, x: i32, y: i32, button: u8) -> anyhow::Result<()> { let (tx_r, rx_r) = bounded(1); tx().send(Cmd::Press { @@ -327,8 +379,8 @@ pub fn press(cursor_id: &str, window_id: u64, x: i32, y: i32, button: u8) -> any .map_err(|e| anyhow::anyhow!("reply channel closed: {e}"))? } -/// Emit motion_absolute on the held cursor's virtual-pointer. Errors if there -/// is no held button for `cursor_id`. +/// Move the held cursor to window-local `(x, y)` on its original target. +/// Errors if there is no held button for `cursor_id`. pub fn move_to(cursor_id: &str, x: i32, y: i32) -> anyhow::Result<()> { let (tx_r, rx_r) = bounded(1); tx().send(Cmd::MoveTo { @@ -384,3 +436,46 @@ pub fn forget(cursor_id: &str) -> anyhow::Result<()> { rx_r.recv() .map_err(|e| anyhow::anyhow!("reply channel closed: {e}"))? } + +#[cfg(test)] +mod tests { + use super::{target_pointer_position, TargetPointerPosition}; + + #[test] + fn target_local_points_include_window_and_negative_output_origins() { + assert_eq!( + target_pointer_position((-1200, 300), (-1920, -200, 3840, 2160), 200, 50), + TargetPointerPosition { + output_x: 920, + output_y: 550, + global_x: -1000, + global_y: 350, + } + ); + } + + #[test] + fn target_relative_motion_preserves_local_delta() { + let start = target_pointer_position((800, 450), (384, 288, 4096, 1440), 20, 30); + let end = target_pointer_position((800, 450), (384, 288, 4096, 1440), 125, 95); + assert_eq!((start.global_x, start.global_y), (820, 480)); + assert_eq!((end.global_x, end.global_y), (925, 545)); + assert_eq!( + (end.output_x - start.output_x, end.output_y - start.output_y), + (105, 65) + ); + } + + #[test] + fn target_points_clamp_after_global_to_output_normalization() { + assert_eq!( + target_pointer_position((5000, -5000), (-100, -50, 200, 100), 50, 50), + TargetPointerPosition { + output_x: 199, + output_y: 0, + global_x: 99, + global_y: -50, + } + ); + } +}