standard-plugin-sdk 0.1.1

Write Standard Code plugins in Rust: wasm components against standard:plugin@2.0.0
Documentation
# Grants

A plugin can do anything inside its own namespace (`<id>.*`) without asking.
Everything else is a *grant* in the manifest, and every grant carries a
reason:

```json
"grants": {
  "values.read:git.*": "Reads the branch state the git companion records",
  "secret:GITHUB_TOKEN": "Signs in to GitHub to read pull request checks"
}
```

- The reason is one sentence the install screen shows under the grant:
  1 to 140 characters after trimming. A grant without one, or with a
  longer one, is rejected by the host and by `standard-plugin check`.
- The user approves the grants when installing; a new grant in an update
  asks again.
- The host checks every call. A call no grant covers returns
  `call-error::grant-denied(<grant>)`, which the SDK surfaces as
  `Error::GrantDenied { grant }` naming exactly what to add, and the host
  counts the denial (the viewer's frame trace shows it).

## Matching

A grant `kind:subject` covers:

- itself exactly (`values.read:git.main`);
- with a trailing `*`, every subject with that prefix (`values.read:git.*`
  covers `values.read:git.main`; `fetch:*` covers every host). `*` is only
  allowed at the end;
- grants without a subject (`account.read`) cover themselves.

The plugin's own namespace needs no grant for values, live messages and
events: plugin `clock` reads and writes `clock.*`, publishes, withdraws
(`live.delete`) and subscribes to `clock.*`, and emits and listens to
`clock.*` freely. Nothing may emit into `system.*`.

Some interfaces need no grant at all: `config`, `claims`, `health`, `view`
(the viewer's theme, palette, tints, clock, machine and instance), and a
daemon plugin's `daemon-context` (its machine, account, lease epoch and environment;
`machine.full` widens the environment to the whole login environment).

## The grants

`standard-plugin-manifest`'s catalogue (`CATALOGUE`) is the list tools
accept; anything else is an unknown grant.

| Grant | Worlds | Covers |
| --- | --- | --- |
| `values.read:<prefix>` | ui, daemon | `values.get`, `keys`, `watch` outside the own namespace |
| `values.write:<prefix>` | ui, daemon | `values.set`, `delete` outside the own namespace |
| `live.publish:<prefix>` | ui, daemon | `live.publish` outside the own namespace |
| `live.subscribe:<prefix>` | ui, daemon | `live.subscribe`, `unsubscribe` outside the own namespace |
| `events.emit:<namespace>` | ui, daemon | `events.emit` into `global.*` or another plugin's namespace |
| `events.on:<namespace>` | ui, daemon | `events.on`, `off` for `global.*`, `system.*` or another plugin's events |
| `call:<plugin id>` | ui, daemon | `calls.call` to the companion (the host checks `call:<own id>`) |
| `secret:<NAME>` | ui, daemon | `secrets.get(NAME)` |
| `account.read` | ui, daemon | `account.state`, `account.watch`: machines, projects, pane metadata; never pane contents |
| `fetch:<host>` | ui, daemon | HTTP requests to a host (daemon: `wasi:http`, `daemon::http`; also `fetch:<scheme>://<host>[:<port>]`; no UI interface yet) |
| `url.open:<host>` | ui | `url.open` of an `https://` URL on that host in the user's browser, on user input (below); `url.open:*.<domain>` also covers the domain's subdomains, `url.open:*` any host |
| `process.exec:<program>` | daemon | `daemon-process.spawn`/`run` of that program, exactly as spawned (`/tmp/git` is not `git`) |
| `fs.read:<path>` | daemon | `daemon-watch` under the path, and the directory preopened read-only for WASI files |
| `fs.write:<path>` | daemon | The directory preopened read-write for WASI files (reading included) |
| `panes.read` | daemon | `daemon-panes.panes`, `subscribe`, `wait` |
| `panes.write` | daemon | `daemon-panes.create`, `input`, `close` |
| `machine.full` | daemon | Every program, every path (`/` and the home directory preopened read-write), and the network |
| `socket.connect:<host>:<port>` | daemon | A WebSocket the host holds to that server (`wss://` only, `daemon::net::WebSocket`, `daemon-net`); frames arrive as the plugin event `system.net.websocket` |
| `network.full` | daemon | WASI sockets to any address, and HTTP to any host |

The account keeps a plugin's secrets. Set one from a pipe, never an
argument, and remove it the same way:

```sh
printf %s "$GITHUB_TOKEN" | standard plugin secret set my-card GITHUB_TOKEN
standard plugin secret unset my-card GITHUB_TOKEN
```

In a viewer, `C` in the Plugins view opens a prompt for the secrets a
plugin's `secret:` grants name: the value types or pastes as dots, and
Enter stores it on the account. The viewer never keeps or shows it.

A path grant covers its directory and everything beneath it by whole path
components, after symlinks are resolved: `fs.read:/srv/data` covers
`/srv/data/x`, not `/srv/database`, and a link inside it that points
elsewhere does not lead out.

The install and approval screens list `machine.full`, `network.full` and
every `process.exec:` and `fetch:` grant first, marked high risk. The
install dialog says what each grant lets the plugin do in plain language
(`standard_plugin_manifest::grant_text`) above the reason. The grants are
the plugin's requirements, not choices: nothing is ticked one by one, and
the only alternative to installing (or approving an update) is not to.

A UI plugin asking for a daemon-only grant is rejected: it could never use
it.

### Opening URLs

`url::open(url)` (or `cx.open_url(url)`) opens a page in the user's
browser: natively the viewer's machine's system opener (`open` on macOS,
`xdg-open` on Linux, the URL its one argument, never a shell), in a
browser viewer a new tab (`noopener`). Every host checks, in order:

1. the URL is plain `https://` (no credentials, a host of letters, digits,
   dots and dashes, no whitespace or control characters, at most 2048
   bytes), else `Invalid`;
2. a `url.open:` grant covers its host, else
   `GrantDenied { grant: "url.open:<host>" }`;
3. the plugin is handling user input (a key, paste, pointer press or
   command on its surfaces) or was given some within the last second, and
   has not opened a URL for it yet, else `Invalid`.

The browser viewer checks the grant and the input again on its main
thread before it opens anything. A plugin with nothing the user did can
never open a page.

## What `check` warns about

`standard-plugin check` (and `build`, which runs it) reports errors the host
would refuse and warnings for what it would accept but probably should not:

| Warning | Why |
| --- | --- |
| An interface the component imports has no covering grant (`account` without `account.read`, `secrets` without a `secret:` grant, `calls` without `call:<id>`, the daemon interfaces without theirs; `machine.full` covers `daemon-process` and `daemon-watch`, as the host does) | Every call will be denied |
| A grant whose interface the component never imports | Ask for what you use; the user reads every grant |
| A grant on the plugin's own namespace | It needs none |
| **Double play**: a UI plugin with an `events.on:` grant *and* any of `fetch:`, `values.write:`, `events.emit:`, `call:` | See below |

### Double play

A UI plugin runs once in every open viewer. If it listens to shared events
and reacts with an effect, the effect happens once *per viewer*: three open
viewers, three writes, three requests, three emitted events (which other
viewers' instances may react to in turn). `check` flags the combination.
The fixes, in order of preference:

1. Move the effect to a companion daemon, which runs once
   ([working together]working-together.md).
2. Act only in the viewer the user is driving (`view::is_driving()`), and
   claim the effect first (`claims().once(key, ttl, ...)`) so two viewers
   that both believe they drive still act once.