| 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 |