From f0c811d387f2956f84723cb1ae806b45a91a381d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 20:14:29 +0000 Subject: [PATCH] =?UTF-8?q?iris:=20screenshots=20for=20agents=20=E2=80=94?= =?UTF-8?q?=20manifest,=20cargo-building=20sidecar,=20session=20wrapper,?= =?UTF-8?q?=20affordance=20and=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designed 2026-08-20 in conversation; PLUGIN.md is the decision record. Nothing here has run yet: no live install, no build, no capture. The wrapper's two launch branches and the cookie-seeding are written against documented behaviour, not observed behaviour. Co-Authored-By: Claude Fable 5 --- PLUGIN.md | 63 ++++++++++++++ README.md | 44 ++++++++++ affordances/iris.md | 15 ++++ bin/iris-browser | 69 ++++++++++++++++ manifest.ts | 63 ++++++++++++++ sidecar/index.ts | 138 +++++++++++++++++++++++++++++++ skills/iris-screenshots/SKILL.md | 71 ++++++++++++++++ 7 files changed, 463 insertions(+) create mode 100644 PLUGIN.md create mode 100644 README.md create mode 100644 affordances/iris.md create mode 100755 bin/iris-browser create mode 100644 manifest.ts create mode 100644 sidecar/index.ts create mode 100644 skills/iris-screenshots/SKILL.md diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..954624f --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,63 @@ +# Iris — the decision record + +Written 2026-08-20, the day the plugin was designed in conversation and first laid down. The decisions +below are the conversation's residue; where one reverses later, record it here rather than deleting it. + +## Not a fork + +Upstream (`iris-screenshot` on crates.io, github.com/brijr/iris) is used as-is. The obvious reason to +fork was authentication — iris has no `--cookie`/`--header` flags — and the decision was that flags are +the wrong mechanism anyway: credentials on command lines end up in transcripts and process lists. The +session lives in a browser profile the *user* signs into through the desktop plugin, and the capture +borrows a copy. The human authenticates; the agent only ever captures. + +## Build with cargo, in the sidecar + +The owner's call: cargo is a declared dependency, installed before iris, and iris compiles from source +on the machine. No prebuilt releases to host, no artefacts to cross-compile, and the plugin has no +binary of its own to distribute. + +The platform's installer deliberately has no build step (`osDependencies` installs packages and fetches +release binaries, nothing more), so the build lives in the one process the platform starts for a plugin: +the sidecar. First start compiles; later starts probe and skip. `--locked` honours upstream's lockfile — +the platform's frozen-lockfile principle, one language over. + +Known consequence, accepted: **distro cargo is usually too old.** Upstream declares `rust-version = +1.88`; Ubuntu's apt cargo is ~1.75. The manifest still declares distro packages (right on Arch/Fedora, +and the honest default), and the sidecar checks the version *before* building and prints the rustup +one-liner when it refuses. Auto-running rustup's curl-pipe-sh was considered and rejected — that is a +decision the owner makes at a terminal, not one a sidecar makes silently. + +## No browser in `osDependencies` + +Three reasons. Ubuntu's apt `chromium` is a snap stub, so a truthful package map does not exist across +distros. The user prefers Brave, which is Chromium underneath — same CDP, same profile format — so the +right declaration is "any of a family", which `osDependencies` cannot express. And the browser this +plugin *wants* is the desktop plugin's, because a browser nobody can see is a browser nobody can sign +in to. `bin/iris-browser` resolves brave → chromium → chrome at run time and fails with a sentence when +none exists. + +## The profile contract + +`~/.config/officer/browser-profile` is the persistent, per-user profile. The desktop plugin's browser +should launch with `--user-data-dir` pointing there and `--password-store=basic` (portable cookie +encryption — keyring-backed cookies do not survive copying). Captures never open that profile directly: +the wrapper copies `Local State`, `Default/Cookies` and `Default/Login Data` into a throwaway profile, +which avoids Chrome's profile lock entirely and makes the persistent profile read-only to captures. + +This contract predates the desktop plugin. When that plugin lands, this path is what it must honour. + +## Open items, in order of bite + +1. **Nothing has run.** No live install, no capture, no build. The whole verification table is owed. +2. **How chromiumoxide launches** decides which wrapper branch runs. If it passes its own + `--user-data-dir`, the wrapper seeds cookies into that directory; if not, the wrapper appends its + own ephemeral one. Both are written; neither is proven. Also unproven: that a seeded cookie copy is + accepted (Chrome ties cookie encryption to `Local State`, which is copied for exactly that reason). +3. **Members.** The wrapper isolates per-user by `$HOME`, but the binary lands in the *owner's* + `~/.local/bin`, and a confined member's agent cannot read it there. If members should capture, + the binary needs a shared home. Deferred until the desktop plugin defines the member story. +4. **Cookie copy under a live browser.** Copying a SQLite file mid-write can catch a torn state; in + practice the cookie DB is WAL and short-lived reads are fine, but "in practice" is not verified. +5. **`permissions: []`** is honest today (nothing platform-gated to protect) but means the owner cannot + dock-gate or role-gate the plugin's existence. Revisit if an api/ or web/ arrives. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dfe650 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Iris + +Website screenshots for your agents. Install this and every agent you talk to can *see* rendered web +pages — any site's, and your own Officer's — instead of inferring them from source code. + +Built around [iris](https://github.com/brijr/iris), a Rust CLI that renders pages in a real +Chrome-family browser (Brave, Chromium and Chrome all work) and captures them as images: full pages, +single elements, phone and tablet viewports, light and dark. + +## What installing gets you + +- The `iris` CLI, compiled from source on first start and put on the standard path. +- `iris-browser`, a launcher that lets captures reuse the web sessions you are signed into. +- An affordance and a skill, so agents know the tool exists and how to drive it — you never have to + explain it to them. + +There is no screen and no dock tile: the consumer is the agent's shell, not you. + +## Authenticated captures + +Sessions are yours to grant, by signing in — never by handing credentials to an agent. The desktop +plugin's browser keeps a persistent profile at `~/.config/officer/browser-profile`; sign in to a site +there once, and captures ride a read-only copy of those cookies from then on. Each account's sessions +live in that account's own home, so nobody captures with anyone else's logins. + +## Requirements + +- **Rust 1.88 or newer.** The install brings in your distribution's `cargo`; if that is older than 1.88 + (Ubuntu's currently is), the sidecar refuses with the exact rustup one-liner to run — check + `pm2 logs officer-iris`. +- **A Chrome-family browser.** Not installed by this plugin: the desktop plugin is the intended source, + since its browser is also where you sign in. Any Brave/Chromium/Chrome already on the machine works + for unauthenticated captures. + +## State of this plugin, plainly + +- The build-and-supervise sidecar, the wrapper, the affordance and the skill are written but **not yet + verified on a live install** — this plugin has not been through the install table on a real machine. +- The wrapper's session-seeding (copying cookies into the capture profile) is a design that needs live + verification against how iris launches its browser; if iris supplies its own profile directory the + wrapper seeds that instead of overriding it, but neither branch has run yet. +- The desktop plugin this integrates with is not published yet; until it is, the persistent profile is + whatever you put at `~/.config/officer/browser-profile` yourself. +- No screen, no API, no database — deliberate for now, not missing. diff --git a/affordances/iris.md b/affordances/iris.md new file mode 100644 index 0000000..b3da738 --- /dev/null +++ b/affordances/iris.md @@ -0,0 +1,15 @@ +--- +name: Iris +description: Use when you need to SEE a rendered web page — a screenshot of any URL, including this platform's own UI. +version: 1 +--- + +`iris` screenshots live websites: `iris -o /tmp/page.png`, then Read the file to look at it. It +renders with a real browser, so JavaScript apps come out as pixels, not empty HTML. + +For a page behind a login, add `--chrome ~/.local/bin/iris-browser` — a wrapper that reuses the sessions +the user signed into through the desktop's browser. Never ask the user for credentials: if a site needs a +login, ask them to sign in once via the desktop app, then capture. + +If `iris` is not on PATH the sidecar is still building it — `pm2 logs officer-iris` says where it is at. +The full flag reference is the `iris-screenshots` skill. diff --git a/bin/iris-browser b/bin/iris-browser new file mode 100755 index 0000000..d660df0 --- /dev/null +++ b/bin/iris-browser @@ -0,0 +1,69 @@ +#!/bin/sh +# iris-browser — the Chrome-family launcher iris is pointed at (`iris --chrome ~/.local/bin/iris-browser`). +# +# Two jobs, both invisible to iris: +# +# 1. Pick the browser. Brave first — it is Chromium underneath, so CDP and the profile format are the +# same — then Chromium, then Chrome. Iris only knows it asked for "a chrome". +# +# 2. Carry the user's sessions into the capture, read-only. The persistent profile is the one the +# desktop's browser signs into (~/.config/officer/browser-profile); captures ride COPIES of its +# cookies in a throwaway profile, so a capture can never corrupt or lock the profile that holds the +# sessions, and the desktop browser being open never collides with a capture. +# +# Per-user isolation is $HOME: whoever runs iris gets their own profile, their own sessions, and nothing +# of anyone else's. That is the kernel's answer, not this script's. +# +# The launcher (chromiumoxide) may pass its own --user-data-dir. If it does, we seed the sessions INTO +# that directory and change nothing else — overriding it could break how the launcher finds the DevTools +# socket. If it does not, we provide an ephemeral directory ourselves and clean it up. + +set -u + +PROFILE="${OFFICER_BROWSER_PROFILE:-$HOME/.config/officer/browser-profile}" + +BROWSER="" +for candidate in brave-browser brave chromium chromium-browser google-chrome google-chrome-stable; do + if BROWSER="$(command -v "$candidate" 2>/dev/null)" && [ -n "$BROWSER" ]; then + break + fi +done +if [ -z "$BROWSER" ]; then + echo "iris-browser: no Chrome-family browser found (looked for brave, chromium, chrome)" >&2 + echo "iris-browser: install the desktop plugin, or any of the above" >&2 + exit 127 +fi + +# Cookies plus the key that decrypts them. Login Data rides along so "remembered" form logins survive. +# Everything else in a profile is cache and preference noise a capture does not need. +seed_sessions() { + dest="$1" + [ -d "$PROFILE/Default" ] || return 0 + mkdir -p "$dest/Default" + cp "$PROFILE/Local State" "$dest/Local State" 2>/dev/null || true + cp "$PROFILE/Default/Cookies" "$dest/Default/Cookies" 2>/dev/null || true + cp "$PROFILE/Default/Login Data" "$dest/Default/Login Data" 2>/dev/null || true +} + +# Did the launcher already choose a profile directory? +CALLER_DIR="" +for arg in "$@"; do + case "$arg" in + --user-data-dir=*) CALLER_DIR="${arg#--user-data-dir=}" ;; + esac +done + +if [ -n "$CALLER_DIR" ]; then + seed_sessions "$CALLER_DIR" + exec "$BROWSER" "$@" +fi + +EPHEMERAL="$(mktemp -d "${TMPDIR:-/tmp}/iris-profile.XXXXXX")" +trap 'rm -rf "$EPHEMERAL"' EXIT INT TERM +seed_sessions "$EPHEMERAL" + +# --password-store=basic keeps cookie encryption portable between the profile and the copy — without it a +# keyring-backed browser writes cookies the copy cannot decrypt. Appended AFTER the launcher's args, and +# so is our --user-data-dir, because for a repeated switch Chrome honours the last occurrence. +"$BROWSER" "$@" --user-data-dir="$EPHEMERAL" --password-store=basic +exit $? diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..4721259 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,63 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Iris — screenshots of live websites, so agents can SEE rendered pages instead of inferring them. + * + * ── What this plugin actually ships ── + * + * A CLI (`iris`, github.com/brijr/iris) and the knowledge of how to use it. There is no api/, no web/, no + * db/: the consumer is an agent's shell, not a screen. The plugin's value is delivered through three + * channels the platform already carries across an install: + * + * sidecar/ builds the binary once with cargo, installs the browser wrapper, then supervises + * affordances/ the short always-loaded note that tells every agent the tool exists + * skills/ the full flag reference, loaded on demand + * + * ── Why the sidecar builds instead of `release` fetching ── + * + * Upstream is a Rust crate. The install pipeline can install packages and fetch release binaries, but it + * deliberately has no build step — so the one-time `cargo install iris-screenshot` belongs to this + * plugin's own process, which PM2 starts at the end of the install. First start compiles (minutes); + * every later start finds the binary and skips. `pm2 logs officer-iris` is where the build narrates. + * + * ── Why no browser dependency is declared ── + * + * Iris renders through a Chrome-family browser, and Brave, Chromium and Chrome are all fine — the wrapper + * (`bin/iris-browser`) resolves whichever is present. Declaring one here would be wrong twice over: the + * package story differs per distro (Ubuntu's apt chromium is a snap stub), and the browser this plugin + * WANTS is the desktop plugin's, because that is where a person signs in to the sites they ask about. + * The contract with the desktop plugin is the persistent profile at ~/.config/officer/browser-profile — + * see README.md. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '0.1.0', + platform: '>=1.0.0', + + label: 'Iris', + summary: 'Website screenshots for agents — eyes on any rendered page, your own Officer included', + icon: 'Camera', + color: '#8b5cf6', + + // Empty, and honestly so: the surface is a binary agents run in their own shell, which the platform's + // permission gate cannot stand in front of. Whose sessions a capture rides is decided by whose Linux + // user runs it — the wrapper reads $HOME, so the kernel answers, not a grant. An api/ or web/ arriving + // later brings its key with it. + permissions: [], + + osDependencies: [ + { + binary: 'cargo', + reason: + 'Builds the iris screenshot binary from source, once, on first start. Nothing works without the build. ' + + 'Note: iris needs Rust 1.88+; if your distribution ships older, the sidecar log says exactly what to do.', + packages: { apt: 'cargo', dnf: 'cargo', pacman: 'rust', brew: 'rust' }, + }, + { + binary: 'cc', + reason: 'Links the iris binary during that one-time build.', + // No brew entry: macOS has cc once the Xcode command-line tools exist, which Homebrew itself requires. + packages: { apt: 'build-essential', pacman: 'base-devel', dnf: 'gcc' }, + }, + ], +}; diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..fa4c5b2 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,138 @@ +import { chmodSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// The iris sidecar: a builder wearing a supervisor's clothes. +// +// The install pipeline installs packages and fetches release binaries, but deliberately runs no build +// step — so compiling a Rust crate is this plugin's own job, and the sidecar is the only process the +// platform starts for a plugin. First start pays for the build (minutes — watch `pm2 logs officer-iris`); +// every later start finds the binary and settles into a heartbeat. +// +// Everything lands where the platform already looks: `~/.local/bin` is first on the probe path in +// os-deps.ts, so `iris` and `iris-browser` are found by whoever asks, without anyone's PATH changing. + +const name = 'officer-iris'; +const CRATE = 'iris-screenshot'; +// Upstream's declared rust-version. Distro cargo lags real Rust by a year or more, so this is checked +// BEFORE building — a clear refusal with instructions beats cargo's own MSRV error buried in build spam. +const MSRV: readonly [number, number] = [1, 88]; + +const home = homedir(); +const localBin = join(home, '.local', 'bin'); + +// `Bun.which` alone reads the PATH this process started with, which under PM2 is minimal. Look where +// things actually are: our install target first, rustup's home second, then whatever PATH says. +const searchPath = (): string => `${localBin}:${join(home, '.cargo', 'bin')}:${process.env.PATH ?? ''}`; +const which = (binary: string): string | null => Bun.which(binary, { PATH: searchPath() }); + +const log = (message: string): void => console.log(`[${name}] ${message}`); + +/** The wrapper ships in this repository; agents need it on a path they can name. Overwrite = upgrade. */ +function installWrapper(): void { + const source = join(import.meta.dir, '..', 'bin', 'iris-browser'); + const target = join(localBin, 'iris-browser'); + mkdirSync(localBin, { recursive: true }); + copyFileSync(source, target); + chmodSync(target, 0o755); + log(`iris-browser installed to ${target}`); +} + +/** `cargo 1.75.0 (…)` → [1, 75], or null when it will not even say. */ +function cargoVersion(cargo: string): [number, number] | null { + try { + const out = Bun.spawnSync([cargo, '--version'], { stdout: 'pipe', stderr: 'pipe' }); + const match = new TextDecoder().decode(out.stdout).match(/cargo (\d+)\.(\d+)/); + return match ? [Number(match[1]), Number(match[2])] : null; + } catch { + return null; + } +} + +const meetsMsrv = ([major, minor]: [number, number]): boolean => + major > MSRV[0] || (major === MSRV[0] && minor >= MSRV[1]); + +/** + * Build iris into ~/.local/bin. Streams cargo's output straight to PM2's log, because a silent + * multi-minute compile is indistinguishable from a hang. + * + * `--locked` builds against upstream's committed lockfile rather than re-resolving dependencies at + * install time — the platform's frozen-lockfile rule, honoured one language over. + */ +async function build(cargo: string): Promise { + log(`building ${CRATE} — first start only, takes a few minutes`); + const proc = Bun.spawn([cargo, 'install', CRATE, '--locked', '--root', join(home, '.local')], { + stdout: 'inherit', + stderr: 'inherit', + }); + const code = await proc.exited; + if (code !== 0) { + log(`build failed (cargo exited ${code}) — read the output above; restart this process to retry`); + return false; + } + return true; +} + +/** True when the binary is usable. Says why not, with the fix, when it is not. */ +async function ensureIris(): Promise { + const present = which('iris'); + if (present) { + log(`iris ready at ${present}`); + return true; + } + + const cargo = which('cargo'); + if (!cargo) { + log('cargo is not on PATH — the install should have provided it; install rust and restart this process'); + return false; + } + + const version = cargoVersion(cargo); + if (!version || !meetsMsrv(version)) { + log(`${cargo} is ${version ? `cargo ${version.join('.')}` : 'unidentifiable'}, and ${CRATE} needs ${MSRV.join('.')}+`); + log('your distribution\'s rust is too old. Install rustup as the user officer runs as:'); + log(" curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal"); + log('then restart this process: pm2 restart officer-iris'); + return false; + } + + if (!(await build(cargo))) return false; + + const built = which('iris'); + if (!built) { + log('cargo reported success but iris is not in ~/.local/bin — read the build output above'); + return false; + } + log(`iris built and ready at ${built}`); + return true; +} + +log(`started (pid ${process.pid})`); +installWrapper(); +let ready = await ensureIris(); + +// The heartbeat re-probes rather than repeating a remembered answer: a binary deleted out from under us +// shows up here, and a rustup installed after a refusal turns the next restart into a build — which the +// log has already told the owner. Building inside the interval is deliberately avoided: a periodic +// compile that nobody asked for is not a heartbeat. +const beat = setInterval(() => { + const present = which('iris'); + if (present && !ready) { + ready = true; + log(`iris appeared at ${present}`); + } else if (!present && ready) { + ready = false; + log('iris is GONE from PATH — restart this process to rebuild'); + } else { + log(`alive — iris ${present ? 'ready' : 'missing'}`); + } +}, 300_000); + +const shutdown = (signal: string): void => { + log(`${signal} — exiting`); + clearInterval(beat); + process.exit(0); +}; + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/skills/iris-screenshots/SKILL.md b/skills/iris-screenshots/SKILL.md new file mode 100644 index 0000000..6459919 --- /dev/null +++ b/skills/iris-screenshots/SKILL.md @@ -0,0 +1,71 @@ +--- +name: Iris screenshots +description: Use when capturing website screenshots with the iris CLI — flags, authenticated captures, viewports, element capture, batching, and what to do when a capture comes out wrong. +version: 1 +--- + +`iris` (github.com/brijr/iris) renders a URL in a real Chrome-family browser over the DevTools protocol +and writes an image. It waits for fonts, images and animations before capturing, so the output is what a +person would see, not a half-loaded page. + +## The basic loop + +``` +iris https://example.com -o /tmp/shot.png +``` + +Then **Read the file** — that is the whole point. Write captures somewhere disposable (`/tmp`) unless the +user asks to keep them. + +## Authenticated captures + +``` +iris https://site.example/private -o /tmp/private.png --chrome ~/.local/bin/iris-browser +``` + +`iris-browser` picks whichever Chrome-family browser is installed (Brave first, then Chromium, then +Chrome) and seeds the capture with a copy of the cookies from `~/.config/officer/browser-profile` — the +profile the desktop plugin's browser signs into. The copy is read-only with respect to that profile: +captures cannot corrupt or lock it. + +Three rules: + +- **Never ask the user for credentials.** If a capture shows a login page, ask them to sign in to that + site once through the desktop app, then capture again. +- **Whose sessions ride along is decided by `$HOME`** — each Linux user has their own profile, so you can + only ever capture with the sessions of the account you run as. +- **Officer's own UI is behind its login too**, so capturing it needs the same wrapper and a signed-in + desktop session, exactly like any other site. + +## Flags that matter + +| Flag | What it does | +| --- | --- | +| `-o, --out ` | output file (or directory for batches) | +| `-s, --size ` | `desktop`, `iphone`, `ipad`, or `WxH` | +| `--full` | full page height, not just the viewport | +| `--selector ` | capture the first matching element only | +| `--padding ` | breathing room around a selected element | +| `--dark` | emulate `prefers-color-scheme: dark` | +| `--format ` | `png`, `jpg`, `webp` | +| `--wait ` | extra settle time before capture | +| `--wait-for ` | wait until a selector exists | +| `--scale ` | device pixel ratio | +| `--timeout ` | per-page limit | +| `--jobs ` | concurrency for multiple URLs | +| `--chrome ` | which browser binary — for sessions, always `~/.local/bin/iris-browser` | +| `--json` | structured result on stdout | + +Verify a UI change in both themes by capturing twice, once with `--dark`. Verify responsive work with +`-s iphone` beside the desktop shot. + +## When it goes wrong + +- **`iris: command not found`** — the sidecar builds it on first start; `pm2 logs officer-iris` shows + progress or says exactly what is missing (usually Rust older than 1.88, with the fix printed). +- **Capture shows a login page** — the site has no session in the profile. Ask the user to sign in via + the desktop app; do not work around it. +- **`iris-browser: no Chrome-family browser found`** — the machine has no browser; the desktop plugin + provides one. +- **Blank or half-rendered output** — add `--wait-for` on a selector that only exists when the page is + really ready, or raise `--wait`.