8000 feat(sandbox): share the office desktop with people through /pi-desktop by geminixiang · Pull Request #178 · geminixiang/mikan · GitHub
[go: up one dir, main page]

Skip to content

feat(sandbox): share the office desktop with people through /pi-desktop - #178

Merged
geminixiang merged 7 commits into
mainfrom
feat/office-desktop
Oct 12, 2026
Merged

geminixiang merged 7 commits into
mainfrom
feat/office-desktop

Conversation

@geminixiang
@geminixiang geminixiang commented Oct 12, 2026 •
Copy link
Copy Markdown
Owner

Summary

Members of a conversation can open its office desktop from a private /pi-desktop link: a headless Wayland desktop (Sway + wayvnc) in the managed oci sandbox, shown with noVNC behind mikan's web server, where the browser jev_browser drives appears as a normal window. Browser control follows ego-browser: a person can take control (the agent's browser calls stop) and hand it back, and the agent can hand a step such as signing in to the person with jev_browser's handOff. Design and measurements: ADR 0025 and docs/research/browser-sharing-2026-10.md.

New dependency: @novnc/novnc (MPL-2.0, 0.6 MB, no dependencies), approved; only its core/ and vendor/ modules are served.

Architecture

Components

flowchart LR
  subgraph People
    chat["Slack / Telegram / Discord"]
    viewer["Person's browser<br/>/desktop page + noVNC"]
  end

  subgraph Host["mikan host process"]
    cmd["/pi-desktop<br/>DesktopCommandHandler"]
    resolver["ActorExecutionResolver.prepare<br/>(provision office container)"]
    tokens["InMemoryDesktopTokenStore<br/>person + office, 24 h"]
    web["web server<br/>GET /desktop · /desktop/novnc/*<br/>GET/POST /desktop/control<br/>WS /desktop/ws"]
    od["OfficeDesktop<br/>start · status · control · connect"]
    prov["DockerContainerManager<br/>retain() blocks idle stop"]
    jev["jev_browser<br/>status check · handOff"]
  end

  subgraph Container["office container (oci sandbox image)"]
    script["mikan-desktop script"]
    sway["Sway (headless, pixman)"]
    vnc["wayvnc<br/>127.0.0.1:6080 WebSocket"]
    chrome["Chromium via agent-browser<br/>headed on Wayland when desktop runs"]
    term["foot terminal"]
    ctl["/dev/shm/mikan-browser-control<br/>agent | user"]
  end

  chat -- "/pi-desktop" --> cmd
  cmd --> resolver --> prov
  cmd --> od
  cmd --> tokens
  cmd -- "private link" --> chat
  viewer -- "page, control (token)" --> web
  viewer <-- "WebSocket (token)" --> web
  web --> tokens
  web --> od
  od -- "engine exec / exec -i" --> script
  od --> prov
  script --> sway --> vnc
  sway --- chrome
  sway --- term
  script --- ctl
  jev -- "pi-env exec: mikan-desktop status / control user" --> script
  jev -- "pi-env exec: agent-browser (+ Wayland env)" --> chrome
  jev -- "handOff: step + link via responder" --> chat
Loading
  • The host never publishes a container port: the desktop WebSocket goes through <engine> exec -i <container> mikan-desktop connect, like the pi-env connection.
  • Desktop start and the stream use <engine> exec from the host, not pi-env, because pi-env kills the process groups it started when its connection ends.
  • Browser control lives in the container (/dev/shm, cleared on container restart), so jev_browser reads it with the same exec it already uses, and no host state links the web server to a run.

Flows

sequenceDiagram
  autonumber
  actor P as Person
  participant S as Slack
  participant M as mikan host
  participant C as office container
  participant A as Agent run (jev_browser)

  P->>S: /pi-desktop
  S->>M: command
  M->>C: provision (replace a stopped outdated container)
  M->>C: exec mikan-desktop start
  alt image has the desktop
    M-->>P: private link /desktop?token=…
  else container still runs an older image
    M-->>P: "available after the sandbox idles out"
  end
  P->>M: open link, WebSocket /desktop/ws
  M->>C: exec -i mikan-desktop connect (retain container)
  C-->>P: desktop frames (wayvnc)

  A->>C: mikan-desktop status
  C-->>A: display wayland-1, control agent
  A->>C: agent-browser … (headed on Wayland)

  P->>M: POST /desktop/control owner=user
  M->>C: mikan-desktop control user
  A->>C: mikan-desktop status
  C-->>A: control user
  A-->>A: returns person-has-control, no browser command

  A->>C: handOff: mikan-desktop control user
  A-->>S: step + desktop link, turn ends
  P->>M: POST /desktop/control owner=agent
  P->>S: "done"
  S->>A: next turn continues in the same browser
Loading

How I tested

  • npm run lint
  • npm test (full gate: lint, fmt, knip, build, typecheck, tests)
  • npm run build
  • Acceptance eval against real containers (sandbox image built from this branch), baseline from the 1.4.0 release:
Case 1.4.0 Expected Branch
E1 /pi-desktop in a DM no link private /desktop?token= link link
E2 open the link n/a page 200; WebSocket greets RFB 003.008 page 200 text/html; charset=utf-8; ws RFB 003.008
E3 jev_browser with the desktop up 0 window(s); tool ok >=1 Chromium window, tool ok 1 window(s); tool ok
E4 jev_browser, never asked for a desktop ok ok (guard) ok
E12 office that never asked none no sway/wayvnc (guard) none
E5 person takes control n/a (no link) tool reports the person has control control 200; tool reports person has control
E6 person hands back n/a (no link) tool ok control 200; tool ok
E7 agent handOff no handoff post; model calls 2; tool [error] Provide goal or commands, or close: true t link and step posted; no further model call link and step posted; model calls 1; tool no tool result
E8 person tab past the tab limit closed kept kept
E9 idle stop with a viewer n/a (no link) running running
E10 idle stop without a viewer exited exited (guard) exited
E11 forged token n/a (no link) page and WebSocket refused page 404; ws refused
E13 image MB 874 <= 879 875
E13 memory MB, browser open 255 (headless) <= 375 325 (desktop up)
E14a upgrade from 1.4.0, container stopped before <1.4.0 image> exited; after <1.4.0 image> running; link no; ws n/a; reply model answered the command te replaced; link; RFB; browser ok before <1.4.0-era image> exited; after running; link yes; ws RFB 003.008; reply -; browser ok
E14b upgrade from 1.4.0, container running before <1.4.0 image> running; after <1.4.0 image> running; link no; ws n/a; reply model answered the command t no replacement; explains the wait; browser ok before <1.4.0-era image> running; after <1.4.0-era image> running; link no; ws n/a; reply Desktop This conve

E14 ran with a 1.4.0-era image without the desktop as the "before" image, because the local 1.4.0 image could not be re-pulled during the run.

  • Manual: opened the desktop page in a browser on another machine, took control and handed it back; the page resizes the remote display to the window.
  • Slack E2E: 26 of 26 pass on 2034d519. Earlier runs lost S-021 (the model mistyped a long attachment path) and, once, S-011, S-018, and S-019 to a second daemon connected to the same Slack app.

Review follow-up (second commit)

A code review found three blockers and several smaller issues; all fixed with tests:

  • The WebSocket upgrade socket had no error listener, so a client reset during refusal or desktop start crashed the daemon; it now gets one first, and URL parsing moved inside the handled chain.

  • /desktop in a shared conversation on a platform without private replies (Telegram groups) would have posted the link publicly; it now asks for a direct message.

  • handOff on a browser started headless handed over an invisible browser; it now starts the desktop and reopens the current page on it (without the headless browser's cookies).

  • An open connection closes when its link expires; control returns to the agent when the last viewer leaves; a failed hand-off post returns control.

  • mikan-desktop start is serialized with flock and restarts only wayvnc while Sway lives; host exec calls time out after 20 s.

  • Without the desktop, the tab limit counts every tab again; with it, only tabs the tool opened. Status is checked only in oci mode, and handOff must be used alone.

  • WAYLAND_DISPLAY is passed as an absolute path instead of setting XDG_RUNTIME_DIR, because agent-browser keeps its session sockets under XDG_RUNTIME_DIR and other calls would not find the session.

  • /pi-desktop and handOff open the desktop through one OpenDesktop service (src/harness/desktop.ts) that the composition root builds and hands to the command and to runners, so the command no longer builds a harness resolver; in oci mode the resolver's key is the office key that tokens carry.

  • ADR 0025 states that desktop links are bearer tokens like /session and /admin links; binding links to a signed-in person is left to a later change of the portal token model.

The acceptance eval was rerun after these fixes; every row still matches. In the eval, E7 now calls handOff alone and E8 opens the desktop first, following the tool contract.

Second review follow-up

  • handOff decides whether to reopen the page from agent-browser's lifecycle (launched / relaunchedBrowser), not from the tool's own flags, so a page opened with raw commands or a browser relaunched headless after an idle stop is moved onto the desktop. The page reopens under a new session name, because agent-browser's per-session daemon keeps the environment it started with and a headless daemon cannot launch Chromium on Wayland; verified in a real container (headless page, then handOff: one Chromium window with the page on the desktop, control user). With no page open, handOff fails and asks the model to open one with url.
  • handOff works only in direct messages, where the link can be sent privately; in shared conversations it fails without touching control.
  • OfficeDesktop counts viewers and gives control back to the agent 10 s after the last one leaves, so a reload keeps control; the web adapter no longer keeps its own count.
  • The wayvnc-only restart addresses Sway's IPC socket by its process ID, and a full restart clears stale sockets.

Notes for reviewers

  • The in-container contract is the image's deploy/docker/mikan-desktop script; the host runs it with <engine> exec, never through pi-env, whose daemon kills the process groups it starts when its connection ends.
  • jev_browser runs mikan-desktop status before each call (one extra exec); a failed call means no desktop, as in host mode or an older image.
  • A browser started headless before the desktop came up stays headless until it restarts.
  • Mobile can view and tap, but the soft keyboard does not open; left for later.
  • Slack apps need /pi-desktop added to their manifest.

@cloudflare-workers-and-pages
cloudflare-workers-and-pages Bot commented Oct 12, 2026 •
Copy link
Copy Markdown

Deploying mikan with  Cloudflare Pages  Cloudflare Pages

Latest commit: 77d5321
Status: ✅  Deploy successful!
Preview URL: https://2c0f8671.mikan-1wv.pages.dev
Branch Preview URL: https://feat-office-desktop.mikan-1wv.pages.dev

View logs

@geminixiang
geminixiang merged commit 44eed07 into main Oct 12, 2026
7 checks passed
@geminixiang
geminixiang deleted the feat/office-desktop branch October 12, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0