返回 CodeWhale
lease.mjs
根目录 / crates / tui / plugins / computer-use / src / lease.mjs
1 // Human/agent control lease — the input gate for a shared computer.
2 //
3 // On a Codewhale Computer (a Sprite seat), a person and the agent share one
4 // X display and one Chromium. The Engine owns the control lease and writes its
5 // current holder to a small JSON file (CODEWHALE_CU_LEASE_FILE, normally
6 // /run/cw/lease.json, owned by cw-engine). While a person holds it, every
7 // input tool refuses with `computer_busy_human_driving`; observation tools
8 // (screenshot, get_app_state, browser_screenshot, ...) keep working so the
9 // agent can watch and resume after hand-back.
10 //
11 // Unlike stop_computer_control, which is a one-way kill for the session, this
12 // refusal is reversible: when the holder goes back to the agent (or the human
13 // lease expires), input works again with no restart.
14 //
15 // File contract (written atomically by the Engine, read here):
16 // {"holder":"human"|"agent"|null, "since":"<ISO>", "expires_at":"<ISO>"|null, "generation":<int>}
17 // Rules:
18 // - no CODEWHALE_CU_LEASE_FILE: no lease concept (a local desktop), never refuses;
19 // - file absent: nobody holds it, the agent may act;
20 // - holder "human" and expires_at absent or in the future: refuse;
21 // - file present but unreadable or malformed: fail closed (`computer_lease_unreadable`).
22 import fs from "node:fs";
23
24 export const HUMAN_DRIVING = "computer_busy_human_driving";
25 export const LEASE_UNREADABLE = "computer_lease_unreadable";
26
27 export function leaseFile(env = process.env) {
28 const file = env.CODEWHALE_CU_LEASE_FILE;
29 return typeof file === "string" && file.trim() ? file.trim() : null;
30 }
31
32 /** Read the lease. Returns {configured, state:"none"|"human"|"agent"|"unreadable", ...}. */
33 export function readLease({ file = leaseFile(), now = Date.now(), read = (f) => fs.readFileSync(f, "utf8") } = {}) {
34 if (!file) return { configured: false, state: "none" };
35 let raw;
36 try { raw = read(file); } catch (error) {
37 if (error?.code === "ENOENT") return { configured: true, state: "none", file };
38 return { configured: true, state: "unreadable", file, reason: error?.code ?? String(error?.message ?? error) };
39 }
40 let lease;
41 try { lease = JSON.parse(raw); } catch { return { configured: true, state: "unreadable", file, reason: "not JSON" }; }
42 if (!lease || typeof lease !== "object" || Array.isArray(lease)) return { configured: true, state: "unreadable", file, reason: "not an object" };
43 const holder = lease.holder ?? null;
44 if (holder !== null && holder !== "human" && holder !== "agent") return { configured: true, state: "unreadable", file, reason: `unknown holder ${JSON.stringify(holder)}` };
45 const expiresAt = lease.expires_at ?? null;
46 let expiresMs = null;
47 if (expiresAt !== null) {
48 expiresMs = Date.parse(expiresAt);
49 if (!Number.isFinite(expiresMs)) return { configured: true, state: "unreadable", file, reason: "bad expires_at" };
50 }
51 const base = { configured: true, file, since: lease.since ?? null, expires_at: expiresAt, generation: lease.generation ?? null };
52 if (holder === "human" && (expiresMs === null || expiresMs > now)) return { ...base, state: "human" };
53 if (holder === "human") return { ...base, state: "none", expired: true };
54 return { ...base, state: holder === "agent" ? "agent" : "none" };
55 }
56
57 /**
58 * The refusal for an input tool, or null when input may proceed. The shape is
59 * {code, message, extra} so callers can raise it as their own error type.
60 */
61 export function inputRefusal(tool, lease = readLease()) {
62 if (lease.state === "human") {
63 return {
64 code: HUMAN_DRIVING,
65 message: `a person is driving this computer — "${tool}" was not sent. Observation tools still work. Wait for hand-back, then observe again before acting; do not try to work around it.`,
66 extra: { retryable: true, lease: { holder: "human", since: lease.since, expires_at: lease.expires_at, generation: lease.generation } },
67 };
68 }
69 if (lease.state === "unreadable") {
70 return {
71 code: LEASE_UNREADABLE,
72 message: `the control lease could not be read (${lease.reason}); input stays refused until it can be, because the computer may be in a person's hands`,
73 extra: { retryable: true },
74 };
75 }
76 return null;
77 }
78
79 /**
80 * Watch the lease and call onHuman() when it passes to a person, so in-flight
81 * input can be cancelled mid-gesture. Polls (the file is tiny and fs.watch is
82 * unreliable across atomic renames). Returns a stop function.
83 */
84 export function watchLease(onHuman, { file = leaseFile(), intervalMs = 200, read } = {}) {
85 if (!file) return () => {};
86 let last = readLease({ file, read }).state;
87 const timer = setInterval(() => {
88 const state = readLease({ file, read }).state;
89 if (state !== last && (state === "human" || state === "unreadable")) {
90 try { onHuman(state); } catch { /* the watcher must never take the server down */ }
91 }
92 last = state;
93 }, intervalMs);
94 timer.unref?.();
95 return () => clearInterval(timer);
96 }
97
97 lines Plain Text