Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b8da359
🤖 feat: define shared desktop task bindings and service wiring
ThomasK33 Sep 4, 2026
9c195fa
🤖 feat: share desktop sessions with coordinated task input
ThomasK33 Sep 4, 2026
6eb8712
🤖 feat: coordinate mixed-owner desktop reservations atomically
ThomasK33 Sep 4, 2026
35dc51e
🤖 feat: show shared desktop targets in the viewer
ThomasK33 Sep 4, 2026
925d244
🤖 feat: reserve shared desktops across task lifecycle transitions
ThomasK33 Sep 4, 2026
631eead
🤖 fix: preserve newer execution recovery over stale active mirrors
ThomasK33 Sep 4, 2026
f17d0ef
🤖 fix: propagate resolved desktop targets through task results
ThomasK33 Sep 4, 2026
43189e3
🤖 fix: roll back forked task checkouts when post-fork persistence throws
ThomasK33 Sep 4, 2026
64f8ad1
🤖 fix: release a shared desktop when the user stops its child stream
ThomasK33 Sep 4, 2026
2329c86
🤖 fix: release a reawakened shared desktop child on UI stop
ThomasK33 Sep 4, 2026
b68b4c8
🤖 fix: await desktop command termination before releasing input
ThomasK33 Sep 4, 2026
9a9e123
🤖 fix: decide shared desktop release inside the serialized config edit
ThomasK33 Sep 4, 2026
170dffc
🤖 tests: remove obsolete Config method from pin-order fixture
ThomasK33 Sep 4, 2026
931d3ff
🤖 tests: create recovery fault rejections at invocation time
ThomasK33 Sep 4, 2026
4b95931
🤖 fix: settle archived shared desktop children so they never hold or …
ThomasK33 Sep 4, 2026
a08545b
🤖 tests: drop unnecessary assertions in archive settlement fixtures
ThomasK33 Sep 4, 2026
340fd90
🤖 fix: retire stale desktop execution mirrors during archive restoration
ThomasK33 Sep 4, 2026
6a9fe20
🤖 tests: align CI fixtures with desktop targeting and event realms
ThomasK33 Sep 4, 2026
067febc
🤖 fix: revoke shared desktop bridges when workspaces close
ThomasK33 Sep 4, 2026
2f9121f
🤖 tests: target the phone frame explicitly in Pixel assertions
ThomasK33 Sep 4, 2026
57b43b6
🤖 fix: integrate main and isolate monitor test teardown
ThomasK33 Sep 4, 2026
66f454e
🤖 chore: integrate current main startup lifecycle
ThomasK33 Sep 4, 2026
06b1952
🤖 fix: clear orphan task execution mirrors during startup reconciliation
ThomasK33 Sep 4, 2026
ffb8450
🤖 fix: re-validate desktop admission inside the active execution mirr…
ThomasK33 Sep 4, 2026
00042db
🤖 fix: validate desktop admissions inside config transactions
ThomasK33 Sep 4, 2026
cb334a2
🤖 fix: revoke desktop viewers after external config changes
ThomasK33 Sep 4, 2026
633c64a
🤖 fix: serialize desktop input and admission across backends
ThomasK33 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const config: StorybookConfig = {
// src/version.ts existing in the local workspace.
resolve: {
alias: [
{
find: "@novnc/novnc/lib/rfb",
replacement: path.join(process.cwd(), "src/browser/stories/mocks/desktopRfb.ts"),
},
{
find: "@/version",
replacement: path.join(process.cwd(), "src/browser/stories/mocks/version.ts"),
Expand Down
11 changes: 8 additions & 3 deletions docs/agents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,11 @@ subagent:
append_prompt: |
You are a desktop automation sub-agent running in a child workspace.

- Your job: interact with the desktop GUI via screenshot-driven automation.
- Your job: interact with the bound desktop GUI via screenshot-driven automation.
- By default this is the caller's desktop, not a fresh desktop in your checkout. For independent GUI testing, the caller must request task desktop: "isolated"; checkout isolation is separate.
- Always take a screenshot before starting a GUI interaction sequence.
- Follow the grounding loop: screenshot → identify target → act → screenshot to verify.
- Follow the grounding loop: screenshot → identify target → act → screenshot to verify. Run dependent screenshots and actions sequentially, never in parallel.
- Other Mux desktop tools may be excluded during an action, but humans in noVNC, shell commands, and CDP can still change the desktop. Re-ground on fresh screenshots.
- After completing the task, summarize the outcome in your final assistant message with only
the result plus selected evidence (e.g., a final screenshot path).
- Do not expand scope beyond the delegated desktop task.
Expand Down Expand Up @@ -536,6 +538,9 @@ tools:

You are a desktop automation agent.

- **Bound desktop:** Desktop tools use the desktop bound to this agent. New desktop agents share the caller's desktop by default; `task` with `desktop: "isolated"` requests an independent desktop for separate GUI testing. Repository checkout isolation does not select the desktop.
- **Sequential steps:** Run dependent screenshots and actions one at a time. Mux desktop-tool input exclusion does not lock out humans using noVNC, shell commands, or CDP; never assume exclusive control of the GUI.
- **Scope:** Change only what the delegated desktop task requires, preserving unrelated windows and user state.
- **Screenshot-first rule:** Always take a `desktop_screenshot` before beginning any GUI interaction loop. Never act on stale visual state.
- **Grounding loop:** Follow `screenshot → identify target coordinates → act (click/type/drag) → screenshot to verify` for each major interaction. Every major interaction step should end with a screenshot to verify the expected result.
- **Coordinate precision:** Use screenshot analysis to identify precise pixel coordinates for clicks, drags, and other positional actions. Account for window position, display scaling, and DPI before acting.
Expand All @@ -546,7 +551,7 @@ You are a desktop automation agent.
- If an unexpected dialog or popup appears, take another screenshot and adapt to the new state.
- **Scrolling:** Use `desktop_scroll` to navigate within windows, then take a screenshot after scrolling to verify the new content is visible.
- **Error recovery:** If an action does not produce the expected result, take another screenshot, reassess the current state, and retry with adjusted coordinates.
- **Reporting:** When complete, summarize only the outcome and key evidence back to the parent agent, such as the final screenshot confirming success. Do not send raw coordinate logs.
- **Reporting:** When complete, identify the actual desktop changed (shared caller or explicitly isolated), summarize the outcome, and provide key evidence such as a final screenshot. Do not infer that a checkout change proves which desktop changed, and do not send raw coordinate logs.
```

</Accordion>
Expand Down
3 changes: 2 additions & 1 deletion docs/hooks/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -670,11 +670,12 @@ If a value is too large for the environment, it may be omitted (not set). Xum al
</details>

<details>
<summary>task (16)</summary>
<summary>task (17)</summary>

| Env var | JSON path | Type | Description |
| ---------------------------------------------- | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `XUM_TOOL_INPUT_AGENT_ID` | `agentId` | string | — |
| `XUM_TOOL_INPUT_DESKTOP` | `desktop` | enum | Desktop target for sub-agents, independent of checkout isolation. "shared" uses the caller's desktop; "isolated" starts a separate desktop. Defaults to shared for agentId="desktop", isolated otherwise. Only one active shared child can control desktop tools; n &gt; 1 requires isolation. Does not exclude human viewer input, shell tools, or external CDP clients. |
| `XUM_TOOL_INPUT_ISOLATION` | `isolation` | enum | Workspace isolation for the sub-agent. "fork" (the default) runs it in an isolated copy of this workspace created from committed state. "none" runs it directly in this workspace's checkout, sharing the working tree (including uncommitted changes) and skipping the fork + init overhead. Use "none" only for read-only analysis (e.g. the explore agent) or when you instruct the sub-agent to avoid editing shared files, since it can otherwise modify the same files concurrently. Omit to fork. |
| `XUM_TOOL_INPUT_KIND` | `kind` | enum | Task kind. Omit or use "subagent" for the existing child-workspace sub-agent flow; use "workspace" to start a normal full workspace turn. |
| `XUM_TOOL_INPUT_MODEL` | `model` | string | Optional model override for the sub-agent, parsed with the same alias logic as the UI (an alias or a full 'provider:model' string). Omit this unless the user explicitly instructed a specific model — by default the sub-agent inherits the parent's model. Do not assume any particular model is available. |
Expand Down
144 changes: 144 additions & 0 deletions src/browser/features/desktop/DesktopPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
// Keep the fake transport and its events in one realm even after other UI tests install a DOM.
import { GlobalWindow, EventTarget, Event, CustomEvent } from "happy-dom";
import type { APIClient } from "@/browser/contexts/API";

const getBootstrap = mock<APIClient["desktop"]["getBootstrap"]>();
void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({ api: { desktop: { getBootstrap } } }),
}));

class FakeRfb extends EventTarget {
static instances: FakeRfb[] = [];
disconnected = false;
constructor(
_container: HTMLElement,
readonly url: string
) {
super();
FakeRfb.instances.push(this);
queueMicrotask(() => this.dispatchEvent(new Event("connect")));
}
disconnect() {
this.disconnected = true;
}
}
void mock.module("@novnc/novnc/lib/rfb", () => ({ default: FakeRfb }));

import { DesktopPanel } from "./DesktopPanel";

type Bootstrap = Awaited<ReturnType<APIClient["desktop"]["getBootstrap"]>>;
const ownCapability = { available: true as const, width: 1280, height: 720, sessionId: "session" };
const sharedBootstrap: Bootstrap = {
capability: {
...ownCapability,
sharedDesktop: { ownerWorkspaceId: "owner", ownerName: "Original desktop" },
},
bridgePath: "/desktop/ws/caller",
token: "caller-token",
};

async function connectedViewer() {
await waitFor(() => expect(FakeRfb.instances.length).toBeGreaterThan(0));
return FakeRfb.instances.at(-1)!;
}

describe("DesktopPanel binding", () => {
let originalWindow: typeof globalThis.window;
let originalDocument: typeof globalThis.document;

beforeEach(() => {
originalWindow = globalThis.window;
originalDocument = globalThis.document;
globalThis.window = new GlobalWindow({ url: "http://localhost" }) as unknown as Window &
typeof globalThis;
globalThis.document = window.document;
FakeRfb.instances = [];
getBootstrap.mockReset();
getBootstrap.mockResolvedValue(sharedBootstrap);
});

afterEach(() => {
cleanup();
globalThis.window = originalWindow;
globalThis.document = originalDocument;
});

test("shows bootstrap binding while connecting with the caller's bridge and token", async () => {
const view = render(<DesktopPanel workspaceId="caller" />);
const viewer = await connectedViewer();
expect(getBootstrap).toHaveBeenCalledWith({ workspaceId: "caller" });
expect(getBootstrap).not.toHaveBeenCalledWith({ workspaceId: "owner" });
expect(viewer.url).toBe("ws://localhost/desktop/ws/caller?token=caller-token");
expect(view.getByText(/Original desktop/)).toBeTruthy();
});

test("does not show a shared target for an independent desktop", async () => {
getBootstrap.mockResolvedValue({ ...sharedBootstrap, capability: ownCapability });
const view = render(<DesktopPanel workspaceId="isolated" />);
await connectedViewer();
expect(view.queryByText(/Original desktop/)).toBeNull();
});

test("clears the binding after security failure and keeps it cleared when retry bootstrap fails", async () => {
const view = render(<DesktopPanel workspaceId="caller" />);
const viewer = await connectedViewer();
act(() => {
viewer.dispatchEvent(
new CustomEvent("securityfailure", { detail: { status: 1, reason: "expired token" } })
);
});
expect(view.queryByText(/Original desktop/)).toBeNull();
expect(viewer.disconnected).toBe(true);
getBootstrap.mockRejectedValueOnce(new Error("binding removed"));
act(() => view.getByRole("button", { name: "Retry" }).click());
await waitFor(() => expect(getBootstrap).toHaveBeenCalledTimes(2));
expect(view.queryByText(/Original desktop/)).toBeNull();
});

test("clears disconnected target metadata before a reconnect gets a new bootstrap", async () => {
const view = render(<DesktopPanel workspaceId="caller" />);
const viewer = await connectedViewer();
act(() => {
viewer.dispatchEvent(new CustomEvent("disconnect", { detail: { clean: false } }));
});
expect(view.queryByText(/Original desktop/)).toBeNull();
expect(viewer.disconnected).toBe(true);
});

test("disposes the previous binding and bootstraps the newly selected workspace", async () => {
const view = render(<DesktopPanel workspaceId="caller" />);
const previousViewer = await connectedViewer();
getBootstrap.mockResolvedValue({
capability: ownCapability,
bridgePath: "/desktop/ws/isolated",
token: "isolated-token",
});
view.rerender(<DesktopPanel workspaceId="isolated" />);
expect(view.queryByText(/Original desktop/)).toBeNull();
expect(previousViewer.disconnected).toBe(true);
await waitFor(() => expect(FakeRfb.instances).toHaveLength(2));
expect(getBootstrap).toHaveBeenLastCalledWith({ workspaceId: "isolated" });
expect(FakeRfb.instances[1].url).toBe(
"ws://localhost/desktop/ws/isolated?token=isolated-token"
);
expect(view.queryByText(/Original desktop/)).toBeNull();
});

test("ignores a late bootstrap from the workspace that was switched away from", async () => {
const pending = Promise.withResolvers<Bootstrap>();
getBootstrap.mockReturnValueOnce(pending.promise);
const view = render(<DesktopPanel workspaceId="caller" />);
getBootstrap.mockResolvedValue({ ...sharedBootstrap, capability: ownCapability });
view.rerender(<DesktopPanel workspaceId="isolated" />);
await connectedViewer();
await act(async () => {
pending.resolve(sharedBootstrap);
await pending.promise;
});
expect(FakeRfb.instances).toHaveLength(1);
expect(view.queryByText(/Original desktop/)).toBeNull();
expect(getBootstrap).toHaveBeenLastCalledWith({ workspaceId: "isolated" });
});
});
12 changes: 11 additions & 1 deletion src/browser/features/desktop/DesktopPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ function StatusOverlay(props: { desktop: UseDesktopConnectionResult }) {
}

export function DesktopPanel(props: { workspaceId: string }) {
// A workspace switch must dispose the old viewer, token, and shared-target label together.
return <WorkspaceDesktopPanel key={props.workspaceId} workspaceId={props.workspaceId} />;
}

function WorkspaceDesktopPanel(props: { workspaceId: string }) {
const desktop = useDesktopConnection(props.workspaceId);

useEffect(() => {
Expand All @@ -90,7 +95,12 @@ export function DesktopPanel(props: { workspaceId: string }) {
}, []);

return (
<div className="bg-background flex h-full flex-col">
<div className="bg-background flex h-full min-w-0 flex-col">
{desktop.sharedDesktop && (
<div className="text-muted-foreground border-border shrink-0 truncate border-b px-3 py-1.5 text-xs">
Shared desktop · {desktop.sharedDesktop.ownerName}
</div>
)}
{desktop.state === "connected" ? null : <StatusOverlay desktop={desktop} />}
<div
ref={desktop.containerRef}
Expand Down
8 changes: 8 additions & 0 deletions src/browser/features/desktop/useDesktopConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface UseDesktopConnectionResult {
disconnect: () => void;
width: number;
height: number;
sharedDesktop: Extract<DesktopCapability, { available: true }>["sharedDesktop"] | null;
}

type DesktopUnavailableReason = Extract<DesktopCapability, { available: false }>["reason"];
Expand Down Expand Up @@ -108,6 +109,8 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR
const [reason, setReason] = useState<string | null>(null);
const [width, setWidth] = useState<number>(DESKTOP_DEFAULTS.WIDTH);
const [height, setHeight] = useState<number>(DESKTOP_DEFAULTS.HEIGHT);
const [sharedDesktop, setSharedDesktop] =
useState<UseDesktopConnectionResult["sharedDesktop"]>(null);

const rfbRef = useRef<RFB | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -131,6 +134,7 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR
};

const disconnectCurrentRfb = () => {
setSharedDesktop(null);
const currentRfb = rfbRef.current;
rfbRef.current = null;
if (!currentRfb) {
Expand Down Expand Up @@ -199,6 +203,8 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR
setState("checking");

try {
// Shared-target metadata is display-only: the caller's bootstrap/token preserves the
// backend's authorization and binding checks; never bootstrap the owner directly.
const result = await api.desktop.getBootstrap({ workspaceId });
if (generationRef.current !== generation || isDisposedRef.current) {
return;
Expand Down Expand Up @@ -290,6 +296,7 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR
rfb.addEventListener("disconnect", handleDisconnect);
rfb.addEventListener("securityfailure", handleSecurityFailure);
rfbRef.current = rfb;
setSharedDesktop(result.capability.sharedDesktop ?? null);
setState("connecting");
} catch (error) {
if (generationRef.current !== generation || isDisposedRef.current) {
Expand Down Expand Up @@ -326,5 +333,6 @@ export function useDesktopConnection(workspaceId: string): UseDesktopConnectionR
disconnect: disconnectHandleRef.current,
width,
height,
sharedDesktop,
};
}
Loading
Loading