iris: screenshots for agents — manifest, cargo-building sidecar, session wrapper, affordance and skill

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 20:14:29 +00:00
co-authored by Claude Fable 5
commit f0c811d387
7 changed files with 463 additions and 0 deletions
+63
View File
@@ -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.
+44
View File
@@ -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.
+15
View File
@@ -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 <url> -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.
+69
View File
@@ -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 $?
+63
View File
@@ -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' },
},
],
};
+138
View File
@@ -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<boolean> {
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<boolean> {
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'));
+71
View File
@@ -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 <path>` | output file (or directory for batches) |
| `-s, --size <preset>` | `desktop`, `iphone`, `ipad`, or `WxH` |
| `--full` | full page height, not just the viewport |
| `--selector <css>` | capture the first matching element only |
| `--padding <px>` | breathing room around a selected element |
| `--dark` | emulate `prefers-color-scheme: dark` |
| `--format <fmt>` | `png`, `jpg`, `webp` |
| `--wait <ms>` | extra settle time before capture |
| `--wait-for <css>` | wait until a selector exists |
| `--scale <n>` | device pixel ratio |
| `--timeout <secs>` | per-page limit |
| `--jobs <n>` | concurrency for multiple URLs |
| `--chrome <path>` | 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`.