mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
Restore Boat desktop input before computer access
Probe IBus without replacing a healthy daemon. Rebind a failed restored socket to the same user private runtime directory before viewer access or Cua MCP startup; headless execution stays independent. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6e4bd77b58
commit
3308a116d4
6 files changed
+107
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ComputerBackend {
|
||||
id: string,
|
||||
): Promise<{ id: string; status: string }>;
|
||||
renew(record: ComputerRecord): Promise<void>;
|
||||
computerTool(): { command: string; args: string[] };
|
||||
desktop(
|
||||
record: ComputerRecord,
|
||||
): Promise<{ viewerUrl: string; expiresAt: string }>;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user