diff --git a/server/src/modules/computers/adapters/boat.test.ts b/server/src/modules/computers/adapters/boat.test.ts index a988245d06..22096cee50 100644 --- a/server/src/modules/computers/adapters/boat.test.ts +++ b/server/src/modules/computers/adapters/boat.test.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { remoteProgram } from "./remote-program.js"; import { afterEach, describe, it, expect, vi } from "vitest"; -import { boatBackend, processProgram } from "./boat.js"; +import { boatBackend, desktopReadinessProgram, processProgram } from "./boat.js"; import { buildGitAuthInvocation } from "../../../services/git-credentials.js"; import type { ComputerRecord } from "../domain/ledger.js"; const record: ComputerRecord = { @@ -384,3 +384,66 @@ case "$*" in *show*) printf '%s\\n' '${state}';; esac expect(existsSync(f.calls)).toBe(false); }); }); + + +describe("Boat desktop input readiness", () => { + function desktopFixture(initiallyHealthy: boolean) { + const temp = realpathSync(mkdtempSync(join(tmpdir(), "computer-desktop-"))); + roots.push(temp); + const runtime = join(temp, String(process.getuid!())); + const bin = join(temp, "bin"); + const healthy = join(temp, "healthy"); + const calls = join(temp, "repairs"); + mkdirSync(runtime, { mode: 0o700 }); + mkdirSync(bin); + if (initiallyHealthy) writeFileSync(healthy, "ready"); + writeFileSync(join(bin, "ibus"), "#!/bin/sh\nprintf '%s\\n' 'unix:path=/test/ibus'\n", { mode: 0o700 }); + writeFileSync(join(bin, "gdbus"), `#!/bin/sh\ntest -f '${healthy}'\n`, { mode: 0o700 }); + writeFileSync(join(bin, "ibus-daemon"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\ntouch '${healthy}'\n`, { mode: 0o700 }); + const call = (program = desktopReadinessProgram) => spawnSync("python3", ["-c", program.replaceAll("/run/user/", `${temp}/`)], { + encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` }, + }); + return { temp, runtime, healthy, calls, call }; + } + + it("preserves a healthy input service and emits no MCP protocol output", () => { + const f = desktopFixture(true); + const result = f.call(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(existsSync(f.calls)).toBe(false); + }); + + it("repairs a disconnected restored socket and checks it again after another resume", () => { + const f = desktopFixture(false); + expect(f.call().status).toBe(0); + const expected = `--replace --daemonize --xim --address unix:path=${f.runtime}/paperclip-ibus/bus\n`; + expect(readFileSync(f.calls, "utf8")).toBe(expected); + expect(f.call().status).toBe(0); + expect(readFileSync(f.calls, "utf8")).toBe(expected); + rmSync(f.healthy); + expect(f.call().status).toBe(0); + expect(readFileSync(f.calls, "utf8")).toBe(expected.repeat(2)); + }); + + it("rejects a symlink input directory without replacing the daemon", () => { + const f = desktopFixture(false); + symlinkSync(f.temp, join(f.runtime, "paperclip-ibus")); + const result = f.call(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("invalid desktop input directory"); + expect(existsSync(f.calls)).toBe(false); + }); + + it("executes the existing Cua MCP process only after input readiness", () => { + const f = desktopFixture(false); + const tool = boatBackend(async () => "unused").computerTool(); + const cua = join(f.temp, "cua"); + writeFileSync(cua, `#!/bin/sh\nprintf '%s\\n' "$*"\n`, { mode: 0o700 }); + expect(tool.command).toBe("python3"); + const result = f.call(tool.args[1]!.replaceAll("/opt/ascii/cua-driver/cua-driver", cua)); + expect(result.status).toBe(0); + expect(result.stdout).toBe("mcp --socket /run/ascii-cua/driver.sock\n"); + expect(existsSync(f.healthy)).toBe(true); + }); +}); diff --git a/server/src/modules/computers/adapters/boat.ts b/server/src/modules/computers/adapters/boat.ts index aecfbd3cd5..9e436b56a3 100644 --- a/server/src/modules/computers/adapters/boat.ts +++ b/server/src/modules/computers/adapters/boat.ts @@ -19,6 +19,38 @@ const desktopCache = new Map< string, { viewerUrl: string; expiresAt: string } >(); +export const desktopReadinessProgram = String.raw` +import os,sys,json,stat,fcntl,subprocess,time +# Boat restores its persistent home, but Unix sockets there can refer to stale +# kernel listeners. Keep a healthy IBus unchanged; repair only a failed probe. +uid=os.getuid();runtime='/run/user/'+str(uid) +st=os.lstat(runtime) +if not stat.S_ISDIR(st.st_mode) or st.st_uid!=uid:raise RuntimeError('invalid desktop runtime directory') +env={**os.environ,'DISPLAY':':0','XAUTHORITY':'/home/user/.Xauthority','DBUS_SESSION_BUS_ADDRESS':'unix:path='+runtime+'/bus','XDG_RUNTIME_DIR':runtime,'IBUS_ENABLE_SYNC_MODE':'0'} +env.pop('IBUS_ADDRESS',None) +def healthy(): + try: + address=subprocess.run(['ibus','address'],env=env,capture_output=True,text=True,timeout=5) + if address.returncode or not address.stdout.strip().startswith('unix:'):return False + return subprocess.run(['gdbus','call','--address',address.stdout.strip(),'--dest','org.freedesktop.IBus','--object-path','/org/freedesktop/IBus','--method','org.freedesktop.DBus.Peer.Ping'],env=env,capture_output=True,timeout=5).returncode==0 + except subprocess.TimeoutExpired:return False +root=runtime+'/paperclip-ibus' +os.makedirs(root,mode=0o700,exist_ok=True) +st=os.lstat(root) +if not stat.S_ISDIR(st.st_mode) or st.st_uid!=uid:raise RuntimeError('invalid desktop input directory') +os.chmod(root,0o700) +fd=os.open(root+'/lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600) +with os.fdopen(fd,'a') as lock: + fcntl.flock(lock,fcntl.LOCK_EX) + if not healthy(): + result=subprocess.run(['ibus-daemon','--replace','--daemonize','--xim','--address','unix:path='+root+'/bus'],env=env,capture_output=True,timeout=20) + if result.returncode:raise RuntimeError('desktop input repair failed') + for attempt in range(10): + if healthy():break + time.sleep(0.2) + else:raise RuntimeError('desktop input did not become ready') +`; + export const processProgram = String.raw` import os,sys,json,fcntl,subprocess,shutil,re p=json.load(sys.stdin);base='/home/user/.paperclip-owners';os.makedirs(base,exist_ok=True) @@ -389,7 +421,14 @@ print('{}') async renew(record) { await api(record, "PATCH", "", { ttlSeconds: 300 }); }, + computerTool() { + return { + command: "python3", + args: ["-c", `${desktopReadinessProgram}\nos.execv('/opt/ascii/cua-driver/cua-driver',['cua-driver','mcp','--socket','/run/ascii-cua/driver.sock'])`], + }; + }, async desktop(record) { + await execute(record, `${desktopReadinessProgram}\nprint('{}')`, {}); const cached = desktopCache.get(record.id); if (cached && Date.parse(cached.expiresAt) > Date.now() + 60_000) return cached; diff --git a/server/src/modules/computers/application/ports.ts b/server/src/modules/computers/application/ports.ts index 896d68c786..84c4ba51c7 100644 --- a/server/src/modules/computers/application/ports.ts +++ b/server/src/modules/computers/application/ports.ts @@ -53,6 +53,7 @@ export interface ComputerBackend { id: string, ): Promise<{ id: string; status: string }>; renew(record: ComputerRecord): Promise; + computerTool(): { command: string; args: string[] }; desktop( record: ComputerRecord, ): Promise<{ viewerUrl: string; expiresAt: string }>; diff --git a/server/src/modules/computers/application/service.test.ts b/server/src/modules/computers/application/service.test.ts index 5de8a1d598..9d8a55d4fa 100644 --- a/server/src/modules/computers/application/service.test.ts +++ b/server/src/modules/computers/application/service.test.ts @@ -63,6 +63,7 @@ function fixture() { stop: vi.fn(async () => ({ id: "stop_1", status: stopStatus })), stopStatus: vi.fn(async () => ({ id: "stop_1", status: stopStatus })), renew: vi.fn(async () => {}), + computerTool: () => ({ command: "cua-driver", args: ["mcp"] }), desktop: vi.fn(async () => ({ viewerUrl: "https://test.on.boat.dev/#credential", expiresAt: new Date(clock.getTime() + 540_000).toISOString(), diff --git a/server/src/modules/computers/application/service.ts b/server/src/modules/computers/application/service.ts index 292836dee0..0fdf020355 100644 --- a/server/src/modules/computers/application/service.ts +++ b/server/src/modules/computers/application/service.ts @@ -289,6 +289,7 @@ finally: return { owner: ownerRef, runner: runnerFor(false), + computerTool: backend.computerTool(), process: { runner: runnerFor(true), async ingress(input?: { port?: number; path?: string }) { diff --git a/server/src/services/computer-environment-driver.ts b/server/src/services/computer-environment-driver.ts index 9818dc8fc7..9a65627e29 100644 --- a/server/src/services/computer-environment-driver.ts +++ b/server/src/services/computer-environment-driver.ts @@ -131,6 +131,6 @@ export async function computerExecutionTarget(db: Db, lease: EnvironmentLease, i if (port !== binding.listenerPort) throw new Error("computer_runner_port_mismatch"); return ingress(path); }, - computerTool: { command: "/opt/ascii/cua-driver/cua-driver", args: ["mcp", "--socket", "/run/ascii-cua/driver.sock"] }, + computerTool: binding.computerTool, }; }