diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 9d4b23d..8dedcfb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -14,6 +14,7 @@ "packages/plugin/README.md", "packages/plugin/docs/protocol/schemas", "assets", - ".opencode" + ".opencode", + "packages/plugin/docs/protocol/openapi.json" ] } diff --git a/README.md b/README.md index b8bc362..f010007 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ MIT license OpenCode 2.0.16 or newer macOS, Linux, Windows + Protocol v1, OpenAPI 3.1

@@ -54,6 +55,7 @@ and permission requests while it runs, and every Unit is journaled so a stopped

  • Typed results. Pass a zod schema (or a JSON Schema) and get a validated value back. When a model answers in prose, the engine repairs or extracts the value.
  • Any model per Unit. model: "provider/model#variant" puts different models in one Run, for example an independent verifier.
  • Watch and steer. A TUI panel and a web app show phases, Units and transcripts. Stop a Run, restart a Unit, answer a question.
  • +
  • Build on it. A versioned protocol with an OpenAPI 3.1 spec: any app can list, start, follow and answer Workflows, no TUI needed.
  • Never hangs headless. Questions carry a fallback answer, and permission requests are denied with a message when nobody is watching.
  • Resumable. Every Run is journaled. Resume replays the finished Units and runs the rest live.
  • Bounded. 5 Units per Run and 5 Runs at once by default, per-provider caps, token budgets and hard limits.
  • @@ -117,20 +119,26 @@ it writes a Workflow for the task at hand and runs it with `workflow_inline` onc ## Install -Add the package to `plugins` in `opencode.json` (one project) or in your global OpenCode config: +```sh +opencode plugin add @malhashemi/opencode-dynamic-workflows +opencode service restart +``` + +That adds the package to your global OpenCode config. The TUI part loads with the server part; there is nothing else +to install. `opencode plugin update` brings later releases. + +Or add it to `plugins` yourself, in a project's `opencode.json` or the global one. The object form takes +[options](#configuration); a project entry for the same package overrides the global one's options: ```jsonc { "$schema": "https://opencode.ai/config.json", - "plugins": ["@malhashemi/opencode-dynamic-workflows"] + "plugins": [{ "package": "@malhashemi/opencode-dynamic-workflows", "options": { "inline": "ask" } }] } ``` -Restart OpenCode. The TUI part loads with the server part; there is nothing else to install. - -To run from a checkout of this repository, run `bun install && bun run build` and put the absolute path of -`packages/plugin` in `plugins` instead. A git spec (`git+https://…#`) works for a repository whose root is the -packed package (`bun run pack` output). +To run from a checkout of this repository, see the [development guide](docs/development.md): a checkout and the +installed package are two plugins to OpenCode, so do not load both in one place. ## Usage @@ -160,21 +168,39 @@ preview arrives in the session that started it (plugin option `notify`). `/workflows` (or leader f) opens the Run library. From there you open a Run (phases, Units, activity, result) and a Unit (prompt, output, transcript). The session you are in also shows its Runs in a strip above -the prompt, in the sidebar and in a run panel (`/workflows panel`). +the prompt, in the sidebar and in a run panel (`/workflows panel`). The header links to the same page in the web app. + +

    + The TUI's Run view: a header card for examples:research (done, 1m12s, 8.2k tokens, $0.66) with its phase stepper plan, research, synthesis and a budget meter; five Units with phase, agent, tokens, cost and time; the typed result as highlighted JSON
    + A Run in the TUI: phases, Units, budget and the typed result. +

    + + + + + + + + + + +
    The TUI's Run library: Live Runs (one running with a progress meter, two waiting) and Recent Runs (done and failed), with phase, Units, tokens, cost, time and the web app's addressA Unit in the TUI: its header card with agent, model and phase, the prompt as a quote, and the output rendered as markdown
    The library: live Runs first, each with its progress.A Unit: its prompt, and its output as markdown.
    | Where | Keys | | --- | --- | -| Library | ↵ open · a answer · f filter · d clean up finished · p pair a browser · r refresh | -| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · p parent session | +| Library | ↵ open · a answer · f filter · d clean up finished · b open in the browser · p pair a device · r refresh | +| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · b open in the browser · p parent session | | Run, when finished | e resume · w save as a durable Workflow · d delete its Unit sessions | +| Approval | ↑/↓, PgUp/PgDn, Home/End scroll the script · ←/→ choose · ↵ confirm | | Anywhere | j/k or arrows to move · ⌫ back | `/workflows` also takes `panel`, `answer`, `cleanup`, `pair`, `refresh`, or a Run id or Workflow name to open. ### In the browser -Every tool result links to its Run in the web app, served by the plugin's Gateway at `http://127.0.0.1:4320` (the next -free port if that one is taken). A browser on the same machine pairs itself for control actions. For a browser on +The web app is served by the plugin's Gateway at `http://127.0.0.1:4320` (the next free port if that one is taken). +The `/workflows` header shows its address and b opens the page you are on; every tool result also links to +its Run. A browser on the same machine pairs itself for control actions. For a browser on another device (with `gateway.bind` set to `lan` or `tailscale`), run `/workflows pair` in the TUI and enter the one-use code. @@ -322,8 +348,15 @@ flowchart LR Inline Workflows are model-written code that runs with your privileges, and they are not sandboxed: the approval is the control. By default a person approves each one (**Run once**, **Always for this project** or **Reject**) after reading the whole source, with its size and SHA-256, in the TUI (`/workflows`, a scrolling view) or the web app. The -request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the project was -approved before. +script is not loaded, so none of its code runs, until then. Saving inline source as a durable Workflow asks the same +way. The request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the +project was approved before. The plugin option `inline` changes this: `"allow"` runs inline Workflows without asking, +`"deny"` never runs them. + +

    + The TUI's approval panel: Run this inline Workflow?, a warning that it runs with your permissions unsandboxed, its size and SHA-256, the whole script with syntax highlighting and line numbers in a scroll box, and the buttons Run once, Always for this project and Reject
    + Approving an inline Workflow: the whole script, highlighted, before any of it runs. +

    Units get OpenCode's permission rules plus the engine's own: `workflow_result` and `question` allowed, `workflow` and `workflow_inline` denied (no recursion). A Unit's permission request goes to a person when one is attached; headless, @@ -333,19 +366,28 @@ The Gateway binds to loopback by default, checks `Host` and `Origin` headers, an remote browsers pair with a one-use code. Read [`docs/security.md`](packages/plugin/docs/security.md) for the trust model, capabilities, limits and data on disk, and [SECURITY.md](SECURITY.md) to report a vulnerability. -## Protocol +## Build on it -Runs, Units, interactions, events and errors form a versioned protocol (v1) with two transports: OpenCode plugin RPC -and the Gateway (HTTP + Server-Sent Events). The zod definitions are the source of truth, and JSON Schemas are -published next to the [protocol reference](packages/plugin/docs/protocol/README.md). TypeScript clients can import the -types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from -`@malhashemi/opencode-dynamic-workflows/rpc`. +Your app can run Workflows too: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do +goes through one versioned protocol (v1, which changes only by addition), with two transports: + +- **OpenCode plugin RPC** on the OpenCode server (`POST /api/rpc/workflow/`), for apps that already talk to + OpenCode. It uses your existing OpenCode credentials; the TypeScript contract is + `@malhashemi/opencode-dynamic-workflows/rpc`, the types `@malhashemi/opencode-dynamic-workflows/protocol`. +- **The Gateway** (HTTP + Server-Sent Events), described by an [OpenAPI 3.1 document](packages/plugin/docs/protocol/openapi.json) + that a running Gateway also serves at `/v1/openapi.json`. ```sh -curl -s http://127.0.0.1:4320/v1/runs | jq '.runs[0]' +curl -s http://127.0.0.1:4320/v1/openapi.json | jq '.paths | keys' curl -N "http://127.0.0.1:4320/v1/events?location=/my/project" ``` +The [integration guide](docs/integrating.md) covers both, including how to be "attached" so that questions and +approvals reach your users. The [protocol reference](packages/plugin/docs/protocol/README.md) and +[JSON Schemas](packages/plugin/docs/protocol/schemas) have the details. If you add support to your app, tell us in an +[issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues): we will list it here and help with what the +protocol is missing. + ## Troubleshooting | Symptom | Fix | diff --git a/assets/tui-approval.webp b/assets/tui-approval.webp new file mode 100644 index 0000000..e5c161d Binary files /dev/null and b/assets/tui-approval.webp differ diff --git a/assets/tui-library.webp b/assets/tui-library.webp new file mode 100644 index 0000000..a43a8df Binary files /dev/null and b/assets/tui-library.webp differ diff --git a/assets/tui-run.webp b/assets/tui-run.webp new file mode 100644 index 0000000..445e577 Binary files /dev/null and b/assets/tui-run.webp differ diff --git a/assets/tui-unit.webp b/assets/tui-unit.webp new file mode 100644 index 0000000..6385572 Binary files /dev/null and b/assets/tui-unit.webp differ diff --git a/docs/development.md b/docs/development.md index a6ccffe..978ed73 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,6 +53,13 @@ entries by path): `WF_GATEWAY` (default `http://127.0.0.1:4320`). - **Worktree Units** (`isolation: "worktree"`) only work when the plugin is configured in a committed `opencode.json` (or globally): worktrees check out the committed tree. +- **Do not load the checkout where the published package is installed.** A checkout (plugin id = its path) and the + npm package (id `@malhashemi/opencode-dynamic-workflows`) are two plugins to OpenCode: both load, both register + the same tools and RPC methods, and either may answer, with its own options (a global `inline: "allow"` would + bypass a project's approval). Test the checkout on a server with its own config directory, as the live tests do: + `XDG_CONFIG_HOME=/tmp/wf-dev-config opencode serve …` with that directory's `opencode/opencode.json` holding your + providers and no plugins. To change the published package's options for one project, name the same package in + the project config: its options override the global ones. ## Checks diff --git a/docs/integrating.md b/docs/integrating.md new file mode 100644 index 0000000..478ecca --- /dev/null +++ b/docs/integrating.md @@ -0,0 +1,210 @@ +# Integrating dynamic workflows into your app + +This guide is for people who build on OpenCode: an ADE, an editor extension, a dashboard, a bot. Everything +the TUI and the web app do is available to you through a versioned protocol, and you do not need the TUI. A +Workflow runs in the OpenCode server, so a user who installs the plugin gets Workflows in your app as well. + +If you add support, [open an issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues) or a pull +request that adds your app to the README. We are glad to help, and to add what the protocol is missing. + +## What you get + +- **Workflows:** list the saved Workflows of a project, with each one's `args` JSON Schema, so you can build a + form for it. +- **Runs:** start a saved Workflow or inline source; follow its phases, Units, tokens and cost live; stop, + resume, restart a Unit; read the result and every Unit's transcript. +- **People in the loop:** show a Workflow's questions, a Unit's permission requests and inline-Workflow + approvals, and send the answers. +- **History:** every Run is journaled, including Runs from before a restart. + +## Two ways in + +| | OpenCode plugin RPC | The Gateway (HTTP + SSE) | +| -------- | --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Address | the OpenCode server: `POST /api/rpc/workflow/` | `http://127.0.0.1:4320/v1/…` (next free port if taken) | +| Auth | whatever your app already uses for OpenCode | a device token for control actions ([pairing](#tokens)) | +| Events | the OpenCode event stream, as `rpc.workflow.event` | Server-Sent Events at `GET /v1/events` | +| Contract | [`@malhashemi/opencode-dynamic-workflows/rpc`](../packages/plugin/src/service/rpc.ts) (typed) | [OpenAPI 3.1](../packages/plugin/docs/protocol/openapi.json), also served at `GET /v1/openapi.json` | +| Best for | apps that already talk to an OpenCode server | scripts, other machines, apps that do not use the OpenCode client | + +Both carry the same resources, commands and events ([protocol reference](../packages/plugin/docs/protocol/README.md), +[JSON Schemas](../packages/plugin/docs/protocol/schemas)). Protocol v1 changes only by addition: new optional +fields, event types and error codes. Ignore what you do not know, and check `protocol` in `info`. + +Detect the plugin with `info`: it answers with the protocol version, the plugin and OpenCode versions, the +capabilities (`runs`, `interactions`, `inline-approval`, …), the limits and the Gateway's address. + +## Be "attached", or nobody can answer + +A Run asks a person three kinds of thing: a Workflow's `ask()` questions, a Unit's permission requests, and the +approval of inline (model-written) Workflows. They wait only while some client is **attached** to the project. +With nobody attached, a question takes its fallback answer, a permission request is denied, and an inline +Workflow is refused, so a Run never hangs. + +- **Plugin RPC:** call `attach({ surface, ttlMs: 45000, sessionID })` with an id unique to this window of your app + (for example `my-app-`), and repeat it every 20 seconds + or so while your app shows Workflows. `sessionID` is the session in view, if any. Call `detach` on exit. +- **Gateway:** an open `GET /v1/events` stream counts as attached. + +Show pending interactions from `run.interactions` (and the `interaction.pending` / `interaction.resolved` +events), and answer with `replyInteraction`: one list of chosen labels per question. For an inline approval, +`interaction.approval` carries the whole `source`, its `sha256` and `bytes`, the `action` (`run` or `save`) and, +for a save, the `target` file. Show the source to the person before they choose; that approval is the only thing +between model-written code and their machine. The choices are `Run once`, `Always for this project` and `Reject` +(`Save` and `Reject` for a save). + +## A Run from start to finish (plugin RPC) + +```ts +import { OpenCode } from "@opencode/client" +import type { PendingInteraction, ProtocolEvent } from "@malhashemi/opencode-dynamic-workflows/protocol" +import { WorkflowRpc } from "@malhashemi/opencode-dynamic-workflows/rpc" + +const directory = "/path/to/project" +const client = OpenCode.make({ baseUrl: "http://127.0.0.1:4096", headers: { "x-opencode-directory": directory } }) +const workflow = client.rpc(WorkflowRpc) +const at = { location: { directory } } + +// Your UI: show the interaction (its questions and options; for an approval, the whole `approval.source`) and +// resolve with the labels the person chose, one list per question. +declare function askPerson(interaction: PendingInteraction): Promise + +// Be attached while your UI is open, so questions and approvals wait for the person. One surface id per window: +// two windows sharing an id would replace each other's attachment, and one's detach would end both. +const surface = `my-app-${crypto.randomUUID()}` +await workflow.attach({ surface, ttlMs: 45_000 }, at) +// The heartbeat also re-reads the Run, so a dropped event (a question, the Run's end) is caught within 20 s. +// For exact recovery, track each event's `seq` and `epoch` and use `eventsSince` (see "Events worth handling"). +const heartbeat = setInterval(() => { + void workflow.attach({ surface, ttlMs: 45_000 }, at) + void reconcile() +}, 20_000) + +let runId: string | null = null +const early: ProtocolEvent[] = [] // events that arrive before startRun returns the Run's id +// An event and a re-read can both see one interaction: ask about each once at a time. Something that failed is +// left undone, so the next re-read tries again. +const asking = new Set() +const answered = new Set() +let finishing = false +let finished = false + +async function answer(interaction: PendingInteraction) { + const id = interaction.interactionId + // A native OpenCode form (`interaction.form`) is answered through OpenCode's own form API, not replyInteraction. + if (!runId || interaction.form || answered.has(id) || asking.has(id)) return + asking.add(id) + try { + const answers = await askPerson(interaction) + await workflow.replyInteraction({ runId, interactionId: id, answers }, at) + answered.add(id) + } finally { + asking.delete(id) + } +} + +async function finish() { + if (!runId || finished || finishing) return + finishing = true + try { + const { status, result } = await workflow.getResult({ runId }, at) + console.log(status, result) + clearInterval(heartbeat) + await workflow.detach({ surface }, at) + finished = true + } finally { + finishing = false + } +} + +// Answer what is pending and finish a Run that has ended, from the Run itself rather than from events. +async function reconcile() { + if (!runId || finished) return + const { run } = await workflow.getRun({ runId }, at) + for (const interaction of run.interactions) await answer(interaction) + if (run.status !== "queued" && run.status !== "running") await finish() +} + +async function handle(event: ProtocolEvent) { + if (event.runId !== runId) return + if (event.type === "interaction.pending") await answer(event.data as PendingInteraction) + if (event.type === "run.ended") await finish() +} + +// Listen before starting, and hold what arrives before the Run's id is known. +workflow.events.on("event", ({ data }) => void (runId ? handle(data) : early.push(data))) + +const { workflows } = await workflow.listWorkflows({}, at) // each with its args JSON Schema +runId = (await workflow.startRun({ name: workflows[0]!.key, args: {} }, at)).runId +for (const event of early.splice(0)) await handle(event) +// Anything that became pending, or a Run that ended, before we were listening for this id. +await reconcile() +``` + +This is a script: if a call throws, the process ends and the heartbeat with it. In a long-lived app, stop the +heartbeat and `detach` when the view closes, whatever happened, or questions keep waiting for a view that is gone. + +A pending interaction with a `form` field is a native OpenCode form (a Unit's `question` call the engine could not +take over). Answer it with OpenCode's form API, `client.session.form.reply({ sessionID, formID, answer })` with +the interaction's `sessionID` and `form.formID`, not with `replyInteraction`. + +Without the typed client, a plugin RPC call is a plain POST to the OpenCode server: the input goes in `input`, +the answer comes back in `output`, and the project is the `x-opencode-directory` header (or `?location=`): + +```sh +curl -s -u "opencode:$OPENCODE_SERVER_PASSWORD" -H "x-opencode-directory: $P" -H "content-type: application/json" \ + -X POST -d '{"input":{}}' http://127.0.0.1:4096/api/rpc/workflow/listWorkflows # → {"output":{"workflows":[…]}} +``` + +## The same over the Gateway + +```sh +GW=http://127.0.0.1:4320 +P=/path/to/project + +curl -s "$GW/v1/info" # versions, capabilities, locations +curl -s "$GW/v1/workflows?location=$P" | jq '.workflows[].key' +curl -N "$GW/v1/events?location=$P" # keep open: live events, and you are attached + +curl -s -X POST "$GW/v1/runs?location=$P" \ + -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \ + -d '{"name":"review-diff","args":{"base":"main"}}' # → { "runId": "…" } + +curl -s -X POST "$GW/v1/runs/$RUN/interactions/$ID/reply" \ + -H "authorization: Bearer $TOKEN" -H "content-type: application/json" \ + -d '{"answers":[["Run once"]]}' + +curl -s "$GW/v1/runs/$RUN/result" +``` + +Every route, parameter and response is in the [OpenAPI document](../packages/plugin/docs/protocol/openapi.json); +point your client generator at it, or at `GET /v1/openapi.json` on a running Gateway. + +## Tokens + +Reads need a token with `read` scope, except from the same machine to a Gateway bound to loopback (the +default). Control actions (start, stop, resume, answer, save, cleanup) need +`Authorization: Bearer ` with `control` scope: + +- A browser page served by the Gateway itself pairs automatically (`POST /v1/pair/local`). +- Anything else gets a one-use code from the TUI (`/workflows pair`, valid five minutes) and exchanges it at + `POST /v1/pair` with `{ "code": "…", "name": "my-app" }`. Store the token; revoke it by deleting its entry in + `$XDG_STATE_HOME/opencode-dynamic-workflows/gateway-tokens.json` (the running Gateway notices at once). +- The plugin RPC route needs no Gateway token: it uses your OpenCode credentials. + +The [security guide](../packages/plugin/docs/security.md) covers the Gateway's host, origin and CORS rules, and +binding it beyond loopback. + +## Events worth handling + +| Event | What to do | +| --------------------------------------------- | ----------------------------------------------------------------------------- | +| `run.started`, `run.updated`, `run.ended` | Update the Run's row: status, phase, usage, result preview. | +| `unit.updated` | Update one Unit. `output` may be elided; fetch the Unit when you show it. | +| `interaction.pending`, `interaction.resolved` | Show or clear a question, permission request or approval. | +| `library.changed` | A Run was added to or removed from history: refresh your list of Runs. | +| `activity.appended` | The Run's log line, phase change or capability use. | +| `resync.required` | You missed events (a restart, or you fell behind): re-read the Runs you show. | + +Events carry a `seq` and an `epoch`. Over SSE, reconnect with `Last-Event-ID` to receive what you missed; over +RPC, `eventsSince({ after, epoch })` does the same. diff --git a/packages/plugin/README.md b/packages/plugin/README.md index 5f39761..13dd214 100644 --- a/packages/plugin/README.md +++ b/packages/plugin/README.md @@ -10,6 +10,7 @@ MIT license OpenCode 2.0.16 or newer macOS, Linux, Windows + Protocol v1, OpenAPI 3.1

    @@ -56,6 +57,7 @@ and permission requests while it runs, and every Unit is journaled so a stopped

  • Typed results. Pass a zod schema (or a JSON Schema) and get a validated value back. When a model answers in prose, the engine repairs or extracts the value.
  • Any model per Unit. model: "provider/model#variant" puts different models in one Run, for example an independent verifier.
  • Watch and steer. A TUI panel and a web app show phases, Units and transcripts. Stop a Run, restart a Unit, answer a question.
  • +
  • Build on it. A versioned protocol with an OpenAPI 3.1 spec: any app can list, start, follow and answer Workflows, no TUI needed.
  • Never hangs headless. Questions carry a fallback answer, and permission requests are denied with a message when nobody is watching.
  • Resumable. Every Run is journaled. Resume replays the finished Units and runs the rest live.
  • Bounded. 5 Units per Run and 5 Runs at once by default, per-provider caps, token budgets and hard limits.
  • @@ -119,20 +121,26 @@ it writes a Workflow for the task at hand and runs it with `workflow_inline` onc ## Install -Add the package to `plugins` in `opencode.json` (one project) or in your global OpenCode config: +```sh +opencode plugin add @malhashemi/opencode-dynamic-workflows +opencode service restart +``` + +That adds the package to your global OpenCode config. The TUI part loads with the server part; there is nothing else +to install. `opencode plugin update` brings later releases. + +Or add it to `plugins` yourself, in a project's `opencode.json` or the global one. The object form takes +[options](#configuration); a project entry for the same package overrides the global one's options: ```jsonc { "$schema": "https://opencode.ai/config.json", - "plugins": ["@malhashemi/opencode-dynamic-workflows"] + "plugins": [{ "package": "@malhashemi/opencode-dynamic-workflows", "options": { "inline": "ask" } }] } ``` -Restart OpenCode. The TUI part loads with the server part; there is nothing else to install. - -To run from a checkout of this repository, run `bun install && bun run build` and put the absolute path of -`packages/plugin` in `plugins` instead. A git spec (`git+https://…#`) works for a repository whose root is the -packed package (`bun run pack` output). +To run from a checkout of this repository, see the [development guide](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/development.md): a checkout and the +installed package are two plugins to OpenCode, so do not load both in one place. ## Usage @@ -162,21 +170,39 @@ preview arrives in the session that started it (plugin option `notify`). `/workflows` (or leader f) opens the Run library. From there you open a Run (phases, Units, activity, result) and a Unit (prompt, output, transcript). The session you are in also shows its Runs in a strip above -the prompt, in the sidebar and in a run panel (`/workflows panel`). +the prompt, in the sidebar and in a run panel (`/workflows panel`). The header links to the same page in the web app. + +

    + The TUI's Run view: a header card for examples:research (done, 1m12s, 8.2k tokens, $0.66) with its phase stepper plan, research, synthesis and a budget meter; five Units with phase, agent, tokens, cost and time; the typed result as highlighted JSON
    + A Run in the TUI: phases, Units, budget and the typed result. +

    + + + + + + + + + + +
    The TUI's Run library: Live Runs (one running with a progress meter, two waiting) and Recent Runs (done and failed), with phase, Units, tokens, cost, time and the web app's addressA Unit in the TUI: its header card with agent, model and phase, the prompt as a quote, and the output rendered as markdown
    The library: live Runs first, each with its progress.A Unit: its prompt, and its output as markdown.
    | Where | Keys | | --- | --- | -| Library | ↵ open · a answer · f filter · d clean up finished · p pair a browser · r refresh | -| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · p parent session | +| Library | ↵ open · a answer · f filter · d clean up finished · b open in the browser · p pair a device · r refresh | +| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · b open in the browser · p parent session | | Run, when finished | e resume · w save as a durable Workflow · d delete its Unit sessions | +| Approval | ↑/↓, PgUp/PgDn, Home/End scroll the script · ←/→ choose · ↵ confirm | | Anywhere | j/k or arrows to move · ⌫ back | `/workflows` also takes `panel`, `answer`, `cleanup`, `pair`, `refresh`, or a Run id or Workflow name to open. ### In the browser -Every tool result links to its Run in the web app, served by the plugin's Gateway at `http://127.0.0.1:4320` (the next -free port if that one is taken). A browser on the same machine pairs itself for control actions. For a browser on +The web app is served by the plugin's Gateway at `http://127.0.0.1:4320` (the next free port if that one is taken). +The `/workflows` header shows its address and b opens the page you are on; every tool result also links to +its Run. A browser on the same machine pairs itself for control actions. For a browser on another device (with `gateway.bind` set to `lan` or `tailscale`), run `/workflows pair` in the TUI and enter the one-use code. @@ -315,8 +341,15 @@ from the first changed prompt on everything runs live. Units that failed run aga Inline Workflows are model-written code that runs with your privileges, and they are not sandboxed: the approval is the control. By default a person approves each one (**Run once**, **Always for this project** or **Reject**) after reading the whole source, with its size and SHA-256, in the TUI (`/workflows`, a scrolling view) or the web app. The -request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the project was -approved before. +script is not loaded, so none of its code runs, until then. Saving inline source as a durable Workflow asks the same +way. The request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the +project was approved before. The plugin option `inline` changes this: `"allow"` runs inline Workflows without asking, +`"deny"` never runs them. + +

    + The TUI's approval panel: Run this inline Workflow?, a warning that it runs with your permissions unsandboxed, its size and SHA-256, the whole script with syntax highlighting and line numbers in a scroll box, and the buttons Run once, Always for this project and Reject
    + Approving an inline Workflow: the whole script, highlighted, before any of it runs. +

    Units get OpenCode's permission rules plus the engine's own: `workflow_result` and `question` allowed, `workflow` and `workflow_inline` denied (no recursion). A Unit's permission request goes to a person when one is attached; headless, @@ -326,19 +359,28 @@ The Gateway binds to loopback by default, checks `Host` and `Origin` headers, an remote browsers pair with a one-use code. Read [`docs/security.md`](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/security.md) for the trust model, capabilities, limits and data on disk, and [SECURITY.md](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/SECURITY.md) to report a vulnerability. -## Protocol +## Build on it -Runs, Units, interactions, events and errors form a versioned protocol (v1) with two transports: OpenCode plugin RPC -and the Gateway (HTTP + Server-Sent Events). The zod definitions are the source of truth, and JSON Schemas are -published next to the [protocol reference](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/README.md). TypeScript clients can import the -types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from -`@malhashemi/opencode-dynamic-workflows/rpc`. +Your app can run Workflows too: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do +goes through one versioned protocol (v1, which changes only by addition), with two transports: + +- **OpenCode plugin RPC** on the OpenCode server (`POST /api/rpc/workflow/`), for apps that already talk to + OpenCode. It uses your existing OpenCode credentials; the TypeScript contract is + `@malhashemi/opencode-dynamic-workflows/rpc`, the types `@malhashemi/opencode-dynamic-workflows/protocol`. +- **The Gateway** (HTTP + Server-Sent Events), described by an [OpenAPI 3.1 document](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/openapi.json) + that a running Gateway also serves at `/v1/openapi.json`. ```sh -curl -s http://127.0.0.1:4320/v1/runs | jq '.runs[0]' +curl -s http://127.0.0.1:4320/v1/openapi.json | jq '.paths | keys' curl -N "http://127.0.0.1:4320/v1/events?location=/my/project" ``` +The [integration guide](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md) covers both, including how to be "attached" so that questions and +approvals reach your users. The [protocol reference](https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/packages/plugin/docs/protocol/README.md) and +[JSON Schemas](https://github.com/malhashemi/opencode-dynamic-workflows/tree/main/packages/plugin/docs/protocol/schemas) have the details. If you add support to your app, tell us in an +[issue](https://github.com/malhashemi/opencode-dynamic-workflows/issues): we will list it here and help with what the +protocol is missing. + ## Troubleshooting | Symptom | Fix | diff --git a/packages/plugin/docs/protocol/README.md b/packages/plugin/docs/protocol/README.md index 169315e..e82a47a 100644 --- a/packages/plugin/docs/protocol/README.md +++ b/packages/plugin/docs/protocol/README.md @@ -9,7 +9,9 @@ and third-party tools. It has two transports that carry the same resources, comm | Gateway (HTTP + SSE) | the web app, scripts, other machines | `http://127.0.0.1:4320/v1/…` by default (see [security](../security.md)) | The zod definitions in `src/protocol/index.ts` are the source of truth. The JSON Schemas in -[`schemas/`](./schemas) are generated from them (`bun run script/protocol-schemas.ts`). TypeScript clients can +[`schemas/`](./schemas) and the Gateway's [OpenAPI 3.1 document](./openapi.json) (also served at +`GET /v1/openapi.json`) are generated from them (`bun run script/protocol-schemas.ts`). For a walkthrough, see the +[integration guide](../../../../docs/integrating.md). TypeScript clients can import the types from `@malhashemi/opencode-dynamic-workflows/protocol` and the RPC contract from `@malhashemi/opencode-dynamic-workflows/rpc`. @@ -61,6 +63,7 @@ script question back to its fallback, dismisses a Unit's question, and rejects a | RPC method | Gateway route | Result | | --- | --- | --- | +| (Gateway only) | `GET /v1/openapi.json` | this API as an OpenAPI 3.1 document | | `info` | `GET /v1/info` | versions, capabilities, limits, Gateway URL (Gateway: every location) | | `listRuns` | `GET /v1/runs?status=&search=&parentSessionID=&since=&limit=&location=` | `{ runs: LibraryEntry[] }` | | `getRun` | `GET /v1/runs/:runId` | `{ run, live }` | diff --git a/packages/plugin/docs/protocol/openapi.json b/packages/plugin/docs/protocol/openapi.json new file mode 100644 index 0000000..9d07872 --- /dev/null +++ b/packages/plugin/docs/protocol/openapi.json @@ -0,0 +1,3710 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenCode dynamic workflows: Gateway API", + "version": "1", + "description": "The HTTP + SSE face of the workflow protocol: start and follow Runs, answer questions and approvals, read\nresults and transcripts. The same resources and commands are also OpenCode plugin RPC methods\n(`POST /api/rpc/workflow/` on the OpenCode server), which an OpenCode client can use with its\nexisting credentials. Guide: https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md\n\nWithin protocol v1 changes are additive only: new optional fields, event types and error codes. Ignore\nwhat you do not know.\n\nAuth: reads need a `read` token, except from the same machine to a loopback Gateway; every action (start,\nstop, answer, …) needs a `control` token. See the bearer scheme and the guide.", + "license": { + "name": "MIT", + "identifier": "MIT" + } + }, + "servers": [ + { + "url": "http://127.0.0.1:4320", + "description": "The default local Gateway (the next free port if taken)" + } + ], + "security": [ + { + "bearer": [] + } + ], + "paths": { + "/v1/openapi.json": { + "get": { + "operationId": "openapi", + "summary": "This document", + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "The OpenAPI document", + "content": { + "application/json": {} + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/info": { + "get": { + "operationId": "info", + "summary": "Versions, capabilities and limits of every location this process serves, and who the caller is", + "security": [ + { + "bearer": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayInfoOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/events": { + "get": { + "operationId": "events", + "summary": "The event stream of one location (Server-Sent Events)", + "description": "Each SSE message has `id: .`, `event: ` and `data: ` (JSON).\nReconnect with the `Last-Event-ID` header (or `?after=.`) to receive what was missed; when the\ngap cannot be filled (the service restarted, or the buffer moved on) a `resync.required` event arrives:\nre-read `GET /v1/runs` and the Runs you show.\n\nAn open stream also marks the location as **attached**: while one is open, questions and inline-Workflow\napprovals wait for an answer instead of being refused or taking their fallback.", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "location", + "in": "query", + "required": false, + "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.", + "schema": { + "type": "string" + } + }, + { + "name": "after", + "in": "query", + "required": false, + "description": "Resume after this event id (`.`); the `Last-Event-ID` header does the same.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "An endless `text/event-stream`; every `data` line is a ProtocolEvent", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/ProtocolEvent" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/workflows": { + "get": { + "operationId": "listWorkflows", + "summary": "The durable (saved) Workflows of a location, with each one's args JSON Schema", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "location", + "in": "query", + "required": false, + "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkflowsOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs": { + "get": { + "operationId": "listRuns", + "summary": "Runs, newest first (live and journaled)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "location", + "in": "query", + "required": false, + "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Only Runs in these states (repeat the parameter for several).", + "schema": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "stopped", + "interrupted" + ] + } + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "parentSessionID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "description": "Only Runs started at or after this time (ms since the epoch).", + "schema": { + "type": "number" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 200 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRunsOutput" + } + } + } + }, + "400": { + "description": "invalid_args: the request does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + }, + "post": { + "operationId": "startRun", + "summary": "Start a Run: a durable Workflow by `name`, or inline `source`", + "description": "Returns at once; follow the Run on the event stream. Inline source needs approval unless the project allows it (plugin option `inline`); the approval is a pending interaction on the Run.", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "location", + "in": "query", + "required": false, + "description": "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRunInput" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: the Run is starting", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRunOutput" + } + } + } + }, + "400": { + "description": "invalid_args: the request does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "429": { + "description": "rate_limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}": { + "get": { + "operationId": "getRun", + "summary": "A Run in full (Units' outputs may be elided; fetch the Unit for the whole value)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetRunOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/result": { + "get": { + "operationId": "getResult", + "summary": "What the Workflow returned", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetResultOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/activity": { + "get": { + "operationId": "getActivity", + "summary": "The Run's activity: logs, phases, engine and capability lines", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivityOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/units/{unitId}": { + "get": { + "operationId": "getUnit", + "summary": "One Unit with its whole output", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "unitId", + "in": "path", + "required": true, + "description": "The Unit's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetUnitOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/units/{unitId}/transcript": { + "get": { + "operationId": "getTranscript", + "summary": "The Unit's session: messages, reasoning and tool calls", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "unitId", + "in": "path", + "required": true, + "description": "The Unit's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTranscriptOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/stop": { + "post": { + "operationId": "stopRun", + "summary": "Stop a Run (running Units are interrupted)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "429": { + "description": "rate_limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/resume": { + "post": { + "operationId": "resumeRun", + "summary": "Resume a finished Run from its journal as a new Run (finished Units replay)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeRunBody" + } + } + } + }, + "responses": { + "202": { + "description": "Accepted: the new Run is starting", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StartRunOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/save": { + "post": { + "operationId": "saveRun", + "summary": "Save the Run's script as a durable Workflow", + "description": "Inline source that no person approved to run needs approval to be saved (a saved Workflow runs without approval): the call waits for it.", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRunBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SaveRunOutput" + } + } + } + }, + "400": { + "description": "invalid_args: the request does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/cleanup": { + "post": { + "operationId": "cleanupRun", + "summary": "Mark a finished Run's Unit sessions for deletion, or record the ones a client deleted", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupRunBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CleanupRunOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/units/{unitId}/stop": { + "post": { + "operationId": "stopUnit", + "summary": "Stop one Unit (it resolves to null in the script)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "unitId", + "in": "path", + "required": true, + "description": "The Unit's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/units/{unitId}/restart": { + "post": { + "operationId": "restartUnit", + "summary": "Restart one Unit in a fresh session", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "unitId", + "in": "path", + "required": true, + "description": "The Unit's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/interactions/{interactionId}/reply": { + "post": { + "operationId": "replyInteraction", + "summary": "Answer a pending question, permission request or inline-Workflow approval", + "description": "`answers` has one entry per question, each a list of chosen option labels (or free text where the question allows `custom`). An approval's options are `Run once`, `Always for this project` and `Reject` (to save: `Save`, `Reject`).", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "interactionId", + "in": "path", + "required": true, + "description": "The pending interaction's id.", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReplyBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkOutput" + } + } + } + }, + "400": { + "description": "invalid_args: the request does not match the schema", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/runs/{runId}/interactions/{interactionId}/cancel": { + "post": { + "operationId": "cancelInteraction", + "summary": "Dismiss a pending interaction (a script question takes its fallback; an approval is refused)", + "security": [ + { + "bearer": [] + } + ], + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "The Run's id.", + "schema": { + "type": "string" + } + }, + { + "name": "interactionId", + "in": "path", + "required": true, + "description": "The pending interaction's id.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OkOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "404": { + "description": "not_found: no such Run, Unit, Workflow or route", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "409": { + "description": "conflict or invalid_state: the Run or interaction is not in a state for this", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/pair": { + "post": { + "operationId": "pair", + "summary": "Exchange a one-use pairing code (from `/workflows pair` in the TUI) for a token", + "security": [ + {} + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairInput" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairOutput" + } + } + } + }, + "401": { + "description": "unauthorized: a token is missing, unknown or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + }, + "429": { + "description": "rate_limited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + }, + "/v1/pair/local": { + "post": { + "operationId": "pairLocal", + "summary": "A browser on this machine, on the Gateway's own origin, pairs itself", + "security": [ + {} + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PairOutput" + } + } + } + }, + "403": { + "description": "forbidden: the token lacks the scope, or the origin or host is not allowed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProtocolError" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearer": { + "type": "http", + "scheme": "bearer", + "description": "A device token from pairing: `read` scope for reads, `control` scope for every action. A client on the same machine as a loopback Gateway may read without one. Tokens never go in URLs." + } + }, + "schemas": { + "GatewayInfoOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "protocol": { + "type": "number", + "const": 1 + }, + "plugin": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "location": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "protocol": { + "type": "number", + "const": 1 + }, + "plugin": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "opencode": { + "type": "string" + }, + "location": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "limits": { + "type": "object", + "properties": { + "maxUnits": { + "type": "number" + }, + "maxItemsPerCall": { + "type": "number" + }, + "maxUnitSteps": { + "type": "number" + }, + "maxConcurrentUnits": { + "type": "number" + }, + "maxConcurrentRuns": { + "type": "number" + }, + "providerConcurrency": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "maxUnits", + "maxItemsPerCall", + "maxUnitSteps" + ], + "additionalProperties": false + }, + "gateway": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "web": { + "type": "boolean" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "plugin", + "opencode", + "location", + "capabilities", + "limits", + "gateway" + ], + "additionalProperties": false + } + }, + "required": [ + "location", + "info" + ], + "additionalProperties": false + } + }, + "auth": { + "type": "object", + "properties": { + "device": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "scopes" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "loopback": { + "type": "boolean" + } + }, + "required": [ + "device", + "loopback" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "plugin", + "locations", + "auth" + ], + "additionalProperties": false + }, + "ListRunsOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "workflow": { + "type": "object", + "properties": { + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "provenance": { + "type": "string", + "enum": [ + "durable", + "inline" + ] + } + }, + "required": [ + "key", + "name", + "description", + "provenance" + ], + "additionalProperties": false + }, + "location": { + "type": "string" + }, + "parentSessionID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "stopped", + "interrupted" + ] + }, + "waiting": { + "type": "boolean" + }, + "units": { + "type": "number" + }, + "settledUnits": { + "type": "number" + }, + "failedUnits": { + "type": "number" + }, + "phases": { + "type": "array", + "items": { + "type": "string" + } + }, + "phasesDeclared": { + "type": "boolean" + }, + "currentPhase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "usage": { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + } + }, + "required": [ + "tokens", + "cost" + ], + "additionalProperties": false + }, + "tokensSpent": { + "type": "number" + }, + "startedAt": { + "type": "number" + }, + "endedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "live": { + "type": "boolean" + } + }, + "required": [ + "runId", + "workflow", + "location", + "parentSessionID", + "status", + "waiting", + "units", + "settledUnits", + "failedUnits", + "phases", + "phasesDeclared", + "currentPhase", + "usage", + "tokensSpent", + "startedAt", + "endedAt", + "live" + ], + "additionalProperties": false + } + } + }, + "required": [ + "runs" + ], + "additionalProperties": false + }, + "GetRunOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "run": { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "workflow": { + "type": "object", + "properties": { + "key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "provenance": { + "type": "string", + "enum": [ + "durable", + "inline" + ] + } + }, + "required": [ + "key", + "name", + "description", + "provenance" + ], + "additionalProperties": false + }, + "location": { + "type": "string" + }, + "parentSessionID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "stopped", + "interrupted" + ] + }, + "waiting": { + "type": "boolean" + }, + "background": { + "type": "boolean" + }, + "phases": { + "type": "array", + "items": { + "type": "string" + } + }, + "phasesDeclared": { + "type": "boolean" + }, + "currentPhase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "units": { + "type": "array", + "items": { + "type": "object", + "properties": { + "unitId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "ordinal": { + "type": "number" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subagent": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "repairing", + "succeeded", + "failed", + "stopped", + "replayed" + ] + }, + "sessionID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prompt": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "requested": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resolved": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requested", + "resolved" + ], + "additionalProperties": false + }, + "schema": { + "type": "boolean" + }, + "resultPath": { + "anyOf": [ + { + "type": "string", + "enum": [ + "text", + "tool", + "text-json", + "extract", + "replay" + ] + }, + { + "type": "null" + } + ] + }, + "attempts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "turn": { + "type": "number" + }, + "path": { + "type": "string", + "enum": [ + "text", + "tool", + "text-json", + "extract", + "replay" + ] + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "turn", + "path", + "ok" + ], + "additionalProperties": false + } + }, + "output": { + "type": "string" + }, + "outputElided": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "usage": { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + } + }, + "required": [ + "tokens", + "cost" + ], + "additionalProperties": false + }, + "startedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "endedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "unitId", + "runId", + "ordinal", + "label", + "subagent", + "phase", + "status", + "sessionID", + "location", + "prompt", + "model", + "schema", + "resultPath", + "attempts", + "usage", + "startedAt", + "endedAt" + ], + "additionalProperties": false + } + }, + "logs": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "unit": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "subagent": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "unit", + "prompt", + "subagent", + "error" + ], + "additionalProperties": false + } + }, + "interactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "interactionId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "unitId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "question", + "permission", + "approval" + ] + }, + "origin": { + "type": "string", + "enum": [ + "script", + "agent", + "permission", + "engine" + ] + }, + "sessionID": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + } + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "header", + "prompt", + "options", + "multiple", + "custom" + ], + "additionalProperties": false + } + }, + "permission": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestID": { + "type": "string" + } + }, + "required": [ + "action", + "resources", + "save", + "requestID" + ], + "additionalProperties": false + }, + "approval": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "run", + "save" + ] + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sha256": { + "type": "string" + }, + "bytes": { + "type": "number" + }, + "source": { + "type": "string" + }, + "requestingSessionID": { + "type": "string" + } + }, + "required": [ + "action", + "target", + "sha256", + "bytes", + "source", + "requestingSessionID" + ], + "additionalProperties": false + }, + "form": { + "type": "object", + "properties": { + "formID": { + "type": "string" + } + }, + "required": [ + "formID" + ], + "additionalProperties": false + }, + "raisedAt": { + "type": "number" + }, + "graceEndsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "interactionId", + "runId", + "unitId", + "kind", + "origin", + "sessionID", + "phase", + "questions", + "raisedAt", + "graceEndsAt" + ], + "additionalProperties": false + } + }, + "resolved": { + "type": "array", + "items": { + "type": "object", + "properties": { + "interactionId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "unitId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "question", + "permission", + "approval" + ] + }, + "origin": { + "type": "string", + "enum": [ + "script", + "agent", + "permission", + "engine" + ] + }, + "sessionID": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": false + } + }, + "multiple": { + "type": "boolean" + }, + "custom": { + "type": "boolean" + } + }, + "required": [ + "header", + "prompt", + "options", + "multiple", + "custom" + ], + "additionalProperties": false + } + }, + "permission": { + "type": "object", + "properties": { + "action": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + }, + "save": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestID": { + "type": "string" + } + }, + "required": [ + "action", + "resources", + "save", + "requestID" + ], + "additionalProperties": false + }, + "approval": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "run", + "save" + ] + }, + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sha256": { + "type": "string" + }, + "bytes": { + "type": "number" + }, + "source": { + "type": "string" + }, + "requestingSessionID": { + "type": "string" + } + }, + "required": [ + "action", + "target", + "sha256", + "bytes", + "source", + "requestingSessionID" + ], + "additionalProperties": false + }, + "form": { + "type": "object", + "properties": { + "formID": { + "type": "string" + } + }, + "required": [ + "formID" + ], + "additionalProperties": false + }, + "raisedAt": { + "type": "number" + }, + "answers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "by": { + "type": "string", + "enum": [ + "human", + "automation" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "answered", + "rejected", + "cancelled" + ] + }, + "resolvedAt": { + "type": "number" + } + }, + "required": [ + "interactionId", + "runId", + "unitId", + "kind", + "origin", + "sessionID", + "phase", + "questions", + "raisedAt", + "answers", + "by", + "resolvedAt" + ], + "additionalProperties": false + } + }, + "usage": { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + } + }, + "required": [ + "tokens", + "cost" + ], + "additionalProperties": false + }, + "tokensSpent": { + "type": "number" + }, + "budget": { + "type": "object", + "properties": { + "total": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "hard": { + "type": "boolean" + } + }, + "required": [ + "total", + "hard" + ], + "additionalProperties": false + }, + "startedAt": { + "type": "number" + }, + "endedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resultPreview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resumeOf": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cleanup": { + "type": "string", + "enum": [ + "none", + "pending", + "done" + ] + }, + "revision": { + "type": "number" + } + }, + "required": [ + "runId", + "workflow", + "location", + "parentSessionID", + "status", + "waiting", + "background", + "phases", + "phasesDeclared", + "currentPhase", + "units", + "logs", + "errors", + "interactions", + "resolved", + "usage", + "tokensSpent", + "budget", + "startedAt", + "endedAt", + "resultPreview", + "resumeOf", + "cleanup", + "revision" + ], + "additionalProperties": false + }, + "live": { + "type": "boolean" + } + }, + "required": [ + "run", + "live" + ], + "additionalProperties": false + }, + "GetUnitOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "unit": { + "type": "object", + "properties": { + "unitId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "ordinal": { + "type": "number" + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subagent": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "repairing", + "succeeded", + "failed", + "stopped", + "replayed" + ] + }, + "sessionID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prompt": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "requested": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resolved": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requested", + "resolved" + ], + "additionalProperties": false + }, + "schema": { + "type": "boolean" + }, + "resultPath": { + "anyOf": [ + { + "type": "string", + "enum": [ + "text", + "tool", + "text-json", + "extract", + "replay" + ] + }, + { + "type": "null" + } + ] + }, + "attempts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "turn": { + "type": "number" + }, + "path": { + "type": "string", + "enum": [ + "text", + "tool", + "text-json", + "extract", + "replay" + ] + }, + "ok": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "turn", + "path", + "ok" + ], + "additionalProperties": false + } + }, + "output": { + "type": "string" + }, + "outputElided": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "usage": { + "type": "object", + "properties": { + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cacheRead": { + "type": "number" + }, + "cacheWrite": { + "type": "number" + } + }, + "required": [ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite" + ], + "additionalProperties": false + }, + "cost": { + "type": "number" + } + }, + "required": [ + "tokens", + "cost" + ], + "additionalProperties": false + }, + "startedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "endedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "unitId", + "runId", + "ordinal", + "label", + "subagent", + "phase", + "status", + "sessionID", + "location", + "prompt", + "model", + "schema", + "resultPath", + "attempts", + "usage", + "startedAt", + "endedAt" + ], + "additionalProperties": false + } + }, + "required": [ + "unit" + ], + "additionalProperties": false + }, + "GetResultOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "succeeded", + "failed", + "stopped", + "interrupted" + ] + }, + "result": {} + }, + "required": [ + "runId", + "status", + "result" + ], + "additionalProperties": false + }, + "ActivityOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "log", + "phase", + "engine", + "capability" + ] + }, + "message": { + "type": "string" + }, + "time": { + "type": "number" + }, + "unitId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "message", + "time", + "unitId" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entries" + ], + "additionalProperties": false + }, + "GetTranscriptOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant", + "system", + "other" + ] + }, + "parts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "text", + "reasoning", + "tool", + "other" + ] + }, + "text": { + "type": "string" + }, + "tool": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "input": { + "type": "string" + }, + "output": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "name", + "status" + ], + "additionalProperties": false + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "role", + "parts", + "model", + "error" + ], + "additionalProperties": false + } + }, + "clipped": { + "type": "boolean" + } + }, + "required": [ + "sessionID", + "messages", + "clipped" + ], + "additionalProperties": false + }, + "ListWorkflowsOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "workflows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "whenToUse": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "detail" + ], + "additionalProperties": false + } + }, + "args": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "scope": { + "type": "string" + } + }, + "required": [ + "key", + "name", + "description", + "whenToUse", + "phases", + "args", + "path", + "scope" + ], + "additionalProperties": false + } + }, + "collisions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "kept": { + "type": "string" + }, + "shadowed": { + "type": "string" + }, + "sameScope": { + "type": "boolean" + } + }, + "required": [ + "key", + "kept", + "shadowed", + "sameScope" + ], + "additionalProperties": false + } + }, + "failures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "required": [ + "path", + "error" + ], + "additionalProperties": false + } + } + }, + "required": [ + "workflows", + "collisions", + "failures" + ], + "additionalProperties": false + }, + "StartRunInput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "args": {}, + "parentSessionID": { + "type": "string" + }, + "requestId": { + "type": "string" + } + }, + "additionalProperties": false + }, + "StartRunOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "runId": { + "type": "string" + } + }, + "required": [ + "runId" + ], + "additionalProperties": false + }, + "ResumeRunBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "rerunFailed": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "SaveRunBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SaveRunOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "key", + "path" + ], + "additionalProperties": false + }, + "CleanupRunBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "deleted": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "CleanupRunOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "deleted": { + "type": "number" + }, + "pending": { + "type": "number" + } + }, + "required": [ + "deleted", + "pending" + ], + "additionalProperties": false + }, + "ReplyBody": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "answers" + ], + "additionalProperties": false + }, + "OkOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "const": true + } + }, + "required": [ + "ok" + ], + "additionalProperties": false + }, + "PairInput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "code" + ], + "additionalProperties": false + }, + "PairOutput": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "token", + "id" + ], + "additionalProperties": false + }, + "ProtocolEvent": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "protocol": { + "type": "number", + "const": 1 + }, + "seq": { + "type": "number" + }, + "epoch": { + "type": "string" + }, + "time": { + "type": "number" + }, + "location": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "run.started", + "run.updated", + "run.ended", + "unit.updated", + "interaction.pending", + "interaction.resolved", + "activity.appended", + "library.changed", + "resync.required" + ] + }, + "revision": { + "type": "number" + }, + "data": {} + }, + "required": [ + "protocol", + "seq", + "time", + "location", + "runId", + "type", + "revision", + "data" + ], + "additionalProperties": false + }, + "ProtocolError": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "not_found", + "conflict", + "invalid_args", + "invalid_state", + "unauthorized", + "forbidden", + "rate_limited", + "unsupported", + "internal" + ] + }, + "message": { + "type": "string" + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "code", + "message", + "retryable" + ], + "additionalProperties": false + } + }, + "required": [ + "error" + ], + "additionalProperties": false + } + } + } +} diff --git a/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json b/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json new file mode 100644 index 0000000..7ba8f0f --- /dev/null +++ b/packages/plugin/docs/protocol/schemas/GatewayInfoOutput.json @@ -0,0 +1,198 @@ +{ + "$id": "https://opencode-dynamic-workflows/protocol/v1/GatewayInfoOutput.json", + "title": "GatewayInfoOutput", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "protocol": { + "type": "number", + "const": 1 + }, + "plugin": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "location": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "protocol": { + "type": "number", + "const": 1 + }, + "plugin": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "additionalProperties": false + }, + "opencode": { + "type": "string" + }, + "location": { + "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string" + } + }, + "limits": { + "type": "object", + "properties": { + "maxUnits": { + "type": "number" + }, + "maxItemsPerCall": { + "type": "number" + }, + "maxUnitSteps": { + "type": "number" + }, + "maxConcurrentUnits": { + "type": "number" + }, + "maxConcurrentRuns": { + "type": "number" + }, + "providerConcurrency": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "maxUnits", + "maxItemsPerCall", + "maxUnitSteps" + ], + "additionalProperties": false + }, + "gateway": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "web": { + "type": "boolean" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "plugin", + "opencode", + "location", + "capabilities", + "limits", + "gateway" + ], + "additionalProperties": false + } + }, + "required": [ + "location", + "info" + ], + "additionalProperties": false + } + }, + "auth": { + "type": "object", + "properties": { + "device": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "name", + "scopes" + ], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "loopback": { + "type": "boolean" + } + }, + "required": [ + "device", + "loopback" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "plugin", + "locations", + "auth" + ], + "additionalProperties": false +} diff --git a/packages/plugin/docs/protocol/schemas/InfoOutput.json b/packages/plugin/docs/protocol/schemas/InfoOutput.json index fb54f6f..ea5ad2d 100644 --- a/packages/plugin/docs/protocol/schemas/InfoOutput.json +++ b/packages/plugin/docs/protocol/schemas/InfoOutput.json @@ -83,6 +83,9 @@ "type": "null" } ] + }, + "web": { + "type": "boolean" } }, "required": [ diff --git a/packages/plugin/docs/security.md b/packages/plugin/docs/security.md index d054950..cdd4d4d 100644 --- a/packages/plugin/docs/security.md +++ b/packages/plugin/docs/security.md @@ -89,7 +89,8 @@ Rules the Gateway enforces: (`POST /v1/pair/local`). A remote browser needs a one-use code from the TUI (`/workflows pair`), valid for five minutes, exchanged at `POST /v1/pair`. - Tokens are stored as SHA-256 hashes in `$XDG_STATE_HOME/opencode-dynamic-workflows/gateway-tokens.json` - (mode 0600). Delete an entry to revoke it. + (mode 0600). Delete an entry to revoke it: the running Gateway re-reads the file when it changes. A missing or + unreadable file grants nothing. - Writes are rate-limited per client. Control actions are recorded in the Run's activity with the device name. - Responses carry a strict Content-Security-Policy, `nosniff`, `no-referrer` and `frame-ancestors 'none'`. diff --git a/packages/plugin/script/protocol-schemas.ts b/packages/plugin/script/protocol-schemas.ts index f516e03..a8d51a9 100644 --- a/packages/plugin/script/protocol-schemas.ts +++ b/packages/plugin/script/protocol-schemas.ts @@ -1,5 +1,6 @@ /** - * Writes `docs/protocol/schemas/.json` from the protocol's zod schemas (the single source of truth). + * Writes `docs/protocol/schemas/.json` and `docs/protocol/openapi.json` from the protocol's zod schemas (the + * single source of truth). * `--check` fails when the committed files are out of date. */ import { mkdir, readFile, writeFile } from "node:fs/promises" @@ -8,6 +9,7 @@ import path from "node:path" import { z } from "zod" import { PROTOCOL_VERSION, PUBLISHED_SCHEMAS } from "../src/protocol" +import { openApiDocument } from "../src/protocol/openapi" const out = path.join(import.meta.dir, "..", "docs", "protocol", "schemas") const check = process.argv.includes("--check") @@ -25,6 +27,12 @@ for (const [name, schema] of Object.entries(PUBLISHED_SCHEMAS)) { if ((await readFile(file, "utf8").catch(() => "")) !== text) stale.push(name) } else await writeFile(file, text) } +// The Gateway's OpenAPI document, beside the schemas. +const openapiFile = path.join(out, "..", "openapi.json") +const openapiText = `${JSON.stringify(openApiDocument(), null, 2)}\n` +if (check) { + if ((await readFile(openapiFile, "utf8").catch(() => "")) !== openapiText) stale.push("openapi.json") +} else await writeFile(openapiFile, openapiText) if (stale.length) { console.error(`protocol schemas out of date: ${stale.join(", ")} — run bun run script/protocol-schemas.ts`) process.exit(1) diff --git a/packages/plugin/src/gateway/auth.ts b/packages/plugin/src/gateway/auth.ts index 3d2f894..f2c9a03 100644 --- a/packages/plugin/src/gateway/auth.ts +++ b/packages/plugin/src/gateway/auth.ts @@ -5,7 +5,7 @@ * expire in five minutes; tokens are stored as SHA-256 hashes in a 0600 file under the user's state directory. */ import { createHash, randomBytes, timingSafeEqual } from "node:crypto" -import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs" import os from "node:os" import path from "node:path" @@ -47,53 +47,87 @@ export async function createTokenStore( file = path.join(gatewayStateDir(), "gateway-tokens.json"), ): Promise { let tokens: DeviceToken[] = [] - try { - const parsed = JSON.parse(await readFile(file, "utf8")) as { tokens?: DeviceToken[] } - tokens = Array.isArray(parsed.tokens) - ? parsed.tokens.filter((t) => typeof t.hash === "string" && typeof t.id === "string") - : [] - } catch { - tokens = [] + /** + * The file's modification and status-change times, size and mode at the last load: a change means someone edited + * it, or changed its permissions (ctime moves on chmod), and it must be read again. + */ + let loadedStamp: string | null = null + const stamp = (): string | null => { + try { + const stat = statSync(file) + return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}:${stat.mode}` + } catch { + return null + } } + /** + * Re-read the file when it changed since the last load, so deleting an entry revokes that token at once, in the + * running Gateway. A missing or unreadable file means no tokens: a broken file never grants access. + */ + const load = () => { + const current = stamp() + if (current !== null && current === loadedStamp) return + loadedStamp = current + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as { tokens?: DeviceToken[] } + tokens = Array.isArray(parsed.tokens) + ? parsed.tokens.filter((t) => typeof t.hash === "string" && typeof t.id === "string") + : [] + } catch { + tokens = [] + } + } + load() const codes = new Map() - const persist = async () => { - await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }) - const temp = `${file}.${randomBytes(6).toString("hex")}.tmp` - await writeFile(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 }) - await rename(temp, file) + /** + * Change the tokens and write them back. Read, change and replace happen synchronously, with nothing awaited in + * between, so an edit made to the file meanwhile (a revocation) is never overwritten with a stale list. + */ + const mutate = (change: (current: DeviceToken[]) => { next: DeviceToken[]; value: T }): T => { + mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }) + load() + const { next, value } = change(tokens) + if (next !== tokens) { + tokens = next + const temp = `${file}.${randomBytes(6).toString("hex")}.tmp` + writeFileSync(temp, `${JSON.stringify({ tokens }, null, 2)}\n`, { mode: 0o600 }) + renameSync(temp, file) + loadedStamp = stamp() + } + return value } const store: TokenStore = { async issue(name, scopes) { const token = `wfg_${randomBytes(24).toString("base64url")}` const id = randomBytes(6).toString("hex") - tokens.push({ + const entry: DeviceToken = { id, name: name.slice(0, 80) || "device", hash: hash(token), scopes, createdAt: Date.now(), lastUsedAt: null, - }) - await persist() - return { token, id } + } + return mutate((current) => ({ next: [...current, entry], value: { token, id } })) }, verify(token) { if (!token.startsWith("wfg_")) return null + load() const digest = hash(token) const match = tokens.find((candidate) => sameHash(candidate.hash, digest)) if (match) match.lastUsedAt = Date.now() return match ?? null }, async revoke(id) { - const before = tokens.length - tokens = tokens.filter((token) => token.id !== id) - if (tokens.length === before) return false - await persist() - return true + return mutate((current) => { + const next = current.filter((token) => token.id !== id) + return next.length === current.length ? { next: current, value: false } : { next, value: true } + }) }, list() { + load() return tokens.map((token) => ({ ...token, scopes: [...token.scopes] })) }, createPairingCode(scopes = ["read", "control"]) { diff --git a/packages/plugin/src/gateway/server.ts b/packages/plugin/src/gateway/server.ts index be7fd7b..3c06012 100644 --- a/packages/plugin/src/gateway/server.ts +++ b/packages/plugin/src/gateway/server.ts @@ -28,6 +28,7 @@ import { type ProtocolErrorCode, type ProtocolEvent, } from "../protocol" +import { openApiDocument } from "../protocol/openapi" import { elideEvent } from "../runs" import type { GatewayConfig } from "../service/config" import { PLUGIN_NAME, PLUGIN_VERSION, type WorkflowService } from "../service/service" @@ -35,6 +36,8 @@ import { bearer, createTokenStore, isLoopbackAddress, type Scope, type TokenStor export interface GatewayHandle { url: string + /** Serves the web app (`gateway.web` of the location that started this process-wide Gateway). */ + web: boolean host: string port: number register(location: string, service: WorkflowService): () => void @@ -404,6 +407,7 @@ export async function startGateway(config: GatewayConfig, options: GatewayOption const location = url.searchParams.get("location") if (request.method === "GET") { + if (parts[0] === "openapi.json" && parts.length === 1) return json(openApiDocument(baseUrl), 200, extra) if (parts[0] === "info" && parts.length === 1) { return json( { @@ -543,6 +547,7 @@ export async function startGateway(config: GatewayConfig, options: GatewayOption const gateway: GatewayHandle = { url: baseUrl, + web: config.web, host: hostname, port, tokens, diff --git a/packages/plugin/src/host/plugin.ts b/packages/plugin/src/host/plugin.ts index c8f0664..4af92cf 100644 --- a/packages/plugin/src/host/plugin.ts +++ b/packages/plugin/src/host/plugin.ts @@ -203,6 +203,7 @@ function ensureService( set: async () => ctx.storage.set(approvalKey, true), }, gatewayUrl: () => (engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.url ?? null, + gatewayWeb: () => (engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.web ?? false, }) holder.gatewayAttached = () => (engineGlobal().singletons.get("gateway") as GatewayHandle | undefined)?.attached(location) ?? false @@ -241,7 +242,9 @@ export async function setup(ctx: Context): Promise<() => Promise> { // Tools // -------------------------------------------------------------------------------------------------------------- - const link = (runId: string) => runLink(service.deps.gatewayUrl(), runId) + // No link to a web app that is turned off. The Gateway is shared by every location and keeps the settings of the + // one that started it, so ask the running Gateway, not this location's config. + const link = (runId: string) => (service.deps.gatewayWeb?.() ? runLink(service.deps.gatewayUrl(), runId) : null) const mirrorProgress = (runId: string, tc: ToolContext) => { let last = "" diff --git a/packages/plugin/src/protocol/index.ts b/packages/plugin/src/protocol/index.ts index 2ec549a..7d23da7 100644 --- a/packages/plugin/src/protocol/index.ts +++ b/packages/plugin/src/protocol/index.ts @@ -389,10 +389,30 @@ export const InfoOutput = z.object({ maxConcurrentRuns: z.number().optional(), providerConcurrency: z.record(z.string(), z.number()).optional(), }), - gateway: z.object({ url: z.string().nullable() }), + gateway: z.object({ + url: z.string().nullable(), + /** Whether the Gateway serves the web app (`gateway.web`); `url` may still serve the API when it does not. */ + web: z.boolean().optional(), + }), }) export type InfoOutput = z.infer +/** The Gateway's `GET /v1/info`: every location the process serves, and who the caller is. */ +export const GatewayInfoOutput = z.object({ + protocol: z.literal(PROTOCOL_VERSION), + plugin: z.object({ name: z.string(), version: z.string() }), + locations: z.array(z.object({ location: z.string(), info: InfoOutput })), + auth: z.object({ + device: z.object({ id: z.string(), name: z.string(), scopes: z.array(z.string()) }).nullable(), + loopback: z.boolean(), + }), +}) +export type GatewayInfoOutput = z.infer + +/** `POST /v1/pair` (a one-use code from `/workflows pair`) and `POST /v1/pair/local`. */ +export const PairInput = z.object({ code: z.string(), name: z.string().optional() }) +export const PairOutput = z.object({ token: z.string(), id: z.string() }) + export const ListRunsInput = z.object({ status: z.array(RunStatus).optional(), parentSessionID: z.string().optional(), @@ -525,6 +545,7 @@ export const PUBLISHED_SCHEMAS = { ProtocolEvent, ProtocolError, InfoOutput, + GatewayInfoOutput, ListRunsInput, StartRunInput, ReplyInteractionInput, diff --git a/packages/plugin/src/protocol/openapi.ts b/packages/plugin/src/protocol/openapi.ts new file mode 100644 index 0000000..b6af86e --- /dev/null +++ b/packages/plugin/src/protocol/openapi.ts @@ -0,0 +1,406 @@ +/** + * The Gateway's HTTP API as an OpenAPI 3.1 document, built from the protocol's zod schemas (the same source as the + * JSON Schemas and the RPC contract). Served at `GET /v1/openapi.json` and written to `docs/protocol/openapi.json`. + * + * `info.version` is the protocol version, not the package version: the document changes only when the API does. + */ +import { z } from "zod" + +import { + ActivityOutput, + CleanupRunOutput, + GatewayInfoOutput, + GetResultOutput, + GetRunOutput, + GetTranscriptOutput, + GetUnitOutput, + ListRunsOutput, + ListWorkflowsOutput, + OkOutput, + PairInput, + PairOutput, + ProtocolError, + ProtocolEvent, + PROTOCOL_VERSION, + RunStatus, + SaveRunOutput, + StartRunInput, + StartRunOutput, +} from "./index" + +type Schema = z.ZodType +type Json = Record + +/** The schemas the document names; each becomes `#/components/schemas/`. */ +const COMPONENTS: Record = { + GatewayInfoOutput, + ListRunsOutput, + GetRunOutput, + GetUnitOutput, + GetResultOutput, + ActivityOutput, + GetTranscriptOutput, + ListWorkflowsOutput, + StartRunInput, + StartRunOutput, + ResumeRunBody: z.object({ + /** Re-run Units that failed or were stopped instead of replaying them as failures. Default true. */ + rerunFailed: z.boolean().optional(), + }), + SaveRunBody: z.object({ name: z.string().optional() }), + SaveRunOutput, + CleanupRunBody: z.object({ deleted: z.array(z.string()).optional() }), + CleanupRunOutput, + ReplyBody: z.object({ answers: z.array(z.array(z.string())) }), + OkOutput, + PairInput, + PairOutput, + ProtocolEvent, + ProtocolError: z.object({ error: ProtocolError }), +} + +const ref = (name: keyof typeof COMPONENTS | string) => ({ $ref: `#/components/schemas/${name}` }) + +const jsonBody = (name: string) => ({ required: true, content: { "application/json": { schema: ref(name) } } }) +const ok = (name: string, status = "200", description = "OK") => ({ + [status]: { description, content: { "application/json": { schema: ref(name) } } }, +}) +const errors = (...codes: number[]) => + Object.fromEntries( + codes.map((code) => [ + String(code), + { + description: ERROR_TEXT[code] ?? "Error", + content: { "application/json": { schema: ref("ProtocolError") } }, + }, + ]), + ) +const ERROR_TEXT: Record = { + 400: "invalid_args: the request does not match the schema", + 401: "unauthorized: a token is missing, unknown or revoked", + 403: "forbidden: the token lacks the scope, or the origin or host is not allowed", + 404: "not_found: no such Run, Unit, Workflow or route", + 409: "conflict or invalid_state: the Run or interaction is not in a state for this", + 429: "rate_limited", +} + +const param = (name: string, description: string, required = true) => ({ + name, + in: "path", + required, + description, + schema: { type: "string" }, +}) +const location = { + name: "location", + in: "query", + required: false, + description: + "The project directory (an OpenCode location) this call is for. Needed when the OpenCode process serves more than one; see `GET /v1/info`.", + schema: { type: "string" }, +} +const runId = param("runId", "The Run's id.") +const unitId = param("unitId", "The Unit's id.") +const interactionId = param("interactionId", "The pending interaction's id.") + +/** + * Reads need a token with `read` scope. A client on the same machine as a loopback Gateway may omit it: OpenAPI + * cannot express a network-dependent exception, so it is documented (in `info` and the bearer scheme) instead. + */ +const READ = [{ bearer: [] }] +/** Control actions: always a token with `control` scope (or `gateway.auth: "none"` on a loopback bind). */ +const CONTROL = [{ bearer: [] }] + +const PATHS: Json = { + "/v1/openapi.json": { + get: { + operationId: "openapi", + summary: "This document", + security: READ, + responses: { + "200": { description: "The OpenAPI document", content: { "application/json": {} } }, + ...errors(401), + }, + }, + }, + "/v1/info": { + get: { + operationId: "info", + summary: "Versions, capabilities and limits of every location this process serves, and who the caller is", + security: READ, + responses: { ...ok("GatewayInfoOutput"), ...errors(401) }, + }, + }, + "/v1/events": { + get: { + operationId: "events", + summary: "The event stream of one location (Server-Sent Events)", + description: [ + "Each SSE message has `id: .`, `event: ` and `data: ` (JSON).", + "Reconnect with the `Last-Event-ID` header (or `?after=.`) to receive what was missed; when the", + "gap cannot be filled (the service restarted, or the buffer moved on) a `resync.required` event arrives:", + "re-read `GET /v1/runs` and the Runs you show.", + "", + "An open stream also marks the location as **attached**: while one is open, questions and inline-Workflow", + "approvals wait for an answer instead of being refused or taking their fallback.", + ].join("\n"), + security: READ, + parameters: [ + location, + { + name: "after", + in: "query", + required: false, + description: "Resume after this event id (`.`); the `Last-Event-ID` header does the same.", + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "An endless `text/event-stream`; every `data` line is a ProtocolEvent", + content: { "text/event-stream": { schema: ref("ProtocolEvent") } }, + }, + ...errors(401, 404), + }, + }, + }, + "/v1/workflows": { + get: { + operationId: "listWorkflows", + summary: "The durable (saved) Workflows of a location, with each one's args JSON Schema", + security: READ, + parameters: [location], + responses: { ...ok("ListWorkflowsOutput"), ...errors(401, 404) }, + }, + }, + "/v1/runs": { + get: { + operationId: "listRuns", + summary: "Runs, newest first (live and journaled)", + security: READ, + parameters: [ + location, + { + name: "status", + in: "query", + required: false, + description: "Only Runs in these states (repeat the parameter for several).", + schema: { type: "array", items: z.toJSONSchema(RunStatus) }, + style: "form", + explode: true, + }, + { name: "search", in: "query", required: false, schema: { type: "string" } }, + { name: "parentSessionID", in: "query", required: false, schema: { type: "string" } }, + { + name: "since", + in: "query", + required: false, + description: "Only Runs started at or after this time (ms since the epoch).", + schema: { type: "number" }, + }, + { name: "limit", in: "query", required: false, schema: { type: "number", default: 200 } }, + ], + responses: { ...ok("ListRunsOutput"), ...errors(400, 401) }, + }, + post: { + operationId: "startRun", + summary: "Start a Run: a durable Workflow by `name`, or inline `source`", + description: + "Returns at once; follow the Run on the event stream. Inline source needs approval unless the project allows it (plugin option `inline`); the approval is a pending interaction on the Run.", + security: CONTROL, + parameters: [location], + requestBody: jsonBody("StartRunInput"), + responses: { + ...ok("StartRunOutput", "202", "Accepted: the Run is starting"), + ...errors(400, 401, 403, 404, 429), + }, + }, + }, + "/v1/runs/{runId}": { + get: { + operationId: "getRun", + summary: "A Run in full (Units' outputs may be elided; fetch the Unit for the whole value)", + security: READ, + parameters: [runId], + responses: { ...ok("GetRunOutput"), ...errors(401, 404) }, + }, + }, + "/v1/runs/{runId}/result": { + get: { + operationId: "getResult", + summary: "What the Workflow returned", + security: READ, + parameters: [runId], + responses: { ...ok("GetResultOutput"), ...errors(401, 404, 409) }, + }, + }, + "/v1/runs/{runId}/activity": { + get: { + operationId: "getActivity", + summary: "The Run's activity: logs, phases, engine and capability lines", + security: READ, + parameters: [runId], + responses: { ...ok("ActivityOutput"), ...errors(401, 404) }, + }, + }, + "/v1/runs/{runId}/units/{unitId}": { + get: { + operationId: "getUnit", + summary: "One Unit with its whole output", + security: READ, + parameters: [runId, unitId], + responses: { ...ok("GetUnitOutput"), ...errors(401, 404) }, + }, + }, + "/v1/runs/{runId}/units/{unitId}/transcript": { + get: { + operationId: "getTranscript", + summary: "The Unit's session: messages, reasoning and tool calls", + security: READ, + parameters: [runId, unitId], + responses: { ...ok("GetTranscriptOutput"), ...errors(401, 404) }, + }, + }, + "/v1/runs/{runId}/stop": { + post: { + operationId: "stopRun", + summary: "Stop a Run (running Units are interrupted)", + security: CONTROL, + parameters: [runId], + responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409, 429) }, + }, + }, + "/v1/runs/{runId}/resume": { + post: { + operationId: "resumeRun", + summary: "Resume a finished Run from its journal as a new Run (finished Units replay)", + security: CONTROL, + parameters: [runId], + requestBody: { ...jsonBody("ResumeRunBody"), required: false }, + responses: { ...ok("StartRunOutput", "202", "Accepted: the new Run is starting"), ...errors(401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/save": { + post: { + operationId: "saveRun", + summary: "Save the Run's script as a durable Workflow", + description: + "Inline source that no person approved to run needs approval to be saved (a saved Workflow runs without approval): the call waits for it.", + security: CONTROL, + parameters: [runId], + requestBody: { ...jsonBody("SaveRunBody"), required: false }, + responses: { ...ok("SaveRunOutput"), ...errors(400, 401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/cleanup": { + post: { + operationId: "cleanupRun", + summary: "Mark a finished Run's Unit sessions for deletion, or record the ones a client deleted", + security: CONTROL, + parameters: [runId], + requestBody: { ...jsonBody("CleanupRunBody"), required: false }, + responses: { ...ok("CleanupRunOutput"), ...errors(401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/units/{unitId}/stop": { + post: { + operationId: "stopUnit", + summary: "Stop one Unit (it resolves to null in the script)", + security: CONTROL, + parameters: [runId, unitId], + responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/units/{unitId}/restart": { + post: { + operationId: "restartUnit", + summary: "Restart one Unit in a fresh session", + security: CONTROL, + parameters: [runId, unitId], + responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/interactions/{interactionId}/reply": { + post: { + operationId: "replyInteraction", + summary: "Answer a pending question, permission request or inline-Workflow approval", + description: + "`answers` has one entry per question, each a list of chosen option labels (or free text where the question allows `custom`). An approval's options are `Run once`, `Always for this project` and `Reject` (to save: `Save`, `Reject`).", + security: CONTROL, + parameters: [runId, interactionId], + requestBody: jsonBody("ReplyBody"), + responses: { ...ok("OkOutput"), ...errors(400, 401, 403, 404, 409) }, + }, + }, + "/v1/runs/{runId}/interactions/{interactionId}/cancel": { + post: { + operationId: "cancelInteraction", + summary: "Dismiss a pending interaction (a script question takes its fallback; an approval is refused)", + security: CONTROL, + parameters: [runId, interactionId], + responses: { ...ok("OkOutput"), ...errors(401, 403, 404, 409) }, + }, + }, + "/v1/pair": { + post: { + operationId: "pair", + summary: "Exchange a one-use pairing code (from `/workflows pair` in the TUI) for a token", + security: [{}], + requestBody: jsonBody("PairInput"), + responses: { ...ok("PairOutput"), ...errors(401, 429) }, + }, + }, + "/v1/pair/local": { + post: { + operationId: "pairLocal", + summary: "A browser on this machine, on the Gateway's own origin, pairs itself", + security: [{}], + responses: { ...ok("PairOutput"), ...errors(403) }, + }, + }, +} + +/** `serverUrl`: the Gateway serving the document (its real address); the committed copy uses the default. */ +export function openApiDocument(serverUrl?: string): Json { + return { + openapi: "3.1.0", + info: { + title: "OpenCode dynamic workflows: Gateway API", + version: `${PROTOCOL_VERSION}`, + description: [ + "The HTTP + SSE face of the workflow protocol: start and follow Runs, answer questions and approvals, read", + "results and transcripts. The same resources and commands are also OpenCode plugin RPC methods", + "(`POST /api/rpc/workflow/` on the OpenCode server), which an OpenCode client can use with its", + "existing credentials. Guide: https://github.com/malhashemi/opencode-dynamic-workflows/blob/main/docs/integrating.md", + "", + "Within protocol v1 changes are additive only: new optional fields, event types and error codes. Ignore", + "what you do not know.", + "", + "Auth: reads need a `read` token, except from the same machine to a loopback Gateway; every action (start,", + "stop, answer, …) needs a `control` token. See the bearer scheme and the guide.", + ].join("\n"), + license: { name: "MIT", identifier: "MIT" }, + }, + servers: serverUrl + ? [{ url: serverUrl.replace(/\/$/, ""), description: "This Gateway" }] + : [{ url: "http://127.0.0.1:4320", description: "The default local Gateway (the next free port if taken)" }], + security: READ, + paths: PATHS, + components: { + securitySchemes: { + bearer: { + type: "http", + scheme: "bearer", + description: + "A device token from pairing: `read` scope for reads, `control` scope for every action. A client on the same machine as a loopback Gateway may read without one. Tokens never go in URLs.", + }, + }, + schemas: Object.fromEntries( + Object.entries(COMPONENTS).map(([name, schema]) => [ + name, + z.toJSONSchema(schema, { target: "draft-2020-12", unrepresentable: "any" }), + ]), + ), + }, + } +} diff --git a/packages/plugin/src/service/service.ts b/packages/plugin/src/service/service.ts index 6587e69..0b78c15 100644 --- a/packages/plugin/src/service/service.ts +++ b/packages/plugin/src/service/service.ts @@ -60,6 +60,8 @@ export interface ServiceDeps { /** Project-level "always allow inline Workflows here" flag (backed by plugin storage). */ approvals: { get(): Promise; set(): Promise } gatewayUrl: () => string | null + /** Whether the running Gateway serves the web app (it is shared by every location, with its own settings). */ + gatewayWeb?: () => boolean } export interface StartOptions { @@ -256,7 +258,7 @@ export class WorkflowService { maxConcurrentRuns: this.deps.config.maxConcurrentRuns, providerConcurrency: { ...this.deps.config.providerConcurrency }, }, - gateway: { url: this.deps.gatewayUrl() }, + gateway: { url: this.deps.gatewayUrl(), web: this.deps.gatewayWeb?.() ?? false }, } } diff --git a/packages/plugin/src/tui/app.tsx b/packages/plugin/src/tui/app.tsx index 67dcca6..3a74c17 100644 --- a/packages/plugin/src/tui/app.tsx +++ b/packages/plugin/src/tui/app.tsx @@ -333,6 +333,23 @@ export function setupWorkflowsTui(context: Context): () => void { async refresh() { await sync.resync("refresh") }, + openInBrowser(url) { + const command = + process.platform === "darwin" + ? ["open", url] + : process.platform === "win32" + ? ["cmd", "/c", "start", "", url] + : ["xdg-open", url] + const fallback = () => toast(`Could not open a browser. Open ${url} yourself.`, "warning") + try { + const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }) + // Never keep OpenCode alive for the opener (xdg-open can outlive the click). + child.unref() + void child.exited.then((code) => (code === 0 ? toast(`Opened ${url}`) : fallback()), fallback) + } catch { + fallback() + } + }, openPanel: (runId, sessionID) => openPanel(runId, sessionID), openRoute: (target) => openRoute(target), } diff --git a/packages/plugin/src/tui/state.ts b/packages/plugin/src/tui/state.ts index c153050..8a6716b 100644 --- a/packages/plugin/src/tui/state.ts +++ b/packages/plugin/src/tui/state.ts @@ -42,6 +42,8 @@ export interface SyncState { readonly runs: Readonly> /** False until the first snapshot has loaded. */ readonly ready: boolean + /** The web app's address (the Gateway), when it is running. */ + readonly webUrl?: string } export type Effect = @@ -82,6 +84,7 @@ export function fromSnapshot(input: { entries: readonly LibraryEntry[] runs: readonly Run[] previous?: SyncState + webUrl?: string | null }): SyncState { const runs: Record = {} for (const entry of input.entries) @@ -96,7 +99,14 @@ export function fromSnapshot(input: { for (const [runId, slot] of Object.entries(input.previous?.runs ?? {})) { if (!runs[runId] && slot.run) runs[runId] = slot } - return { location: input.location, seq: input.seq, ...(input.epoch ? { epoch: input.epoch } : {}), runs, ready: true } + return { + location: input.location, + seq: input.seq, + ...(input.epoch ? { epoch: input.epoch } : {}), + ...(input.webUrl ? { webUrl: input.webUrl } : {}), + runs, + ready: true, + } } /** Apply a full `getRun` snapshot (a hydrate or a view opening a Run). Never goes backwards. */ diff --git a/packages/plugin/src/tui/sync.ts b/packages/plugin/src/tui/sync.ts index 4b762e0..95dfa31 100644 --- a/packages/plugin/src/tui/sync.ts +++ b/packages/plugin/src/tui/sync.ts @@ -161,6 +161,7 @@ export class WorkflowSync { this.set( fromSnapshot({ location: info.location, + webUrl: info.gateway.web === false ? null : info.gateway.url, seq: latest, ...(epoch ? { epoch } : {}), entries, diff --git a/packages/plugin/src/tui/views.tsx b/packages/plugin/src/tui/views.tsx index 96d7a8d..386fcf8 100644 --- a/packages/plugin/src/tui/views.tsx +++ b/packages/plugin/src/tui/views.tsx @@ -99,6 +99,8 @@ export interface Actions { refresh(): Promise openPanel(runId?: string, sessionID?: string): void openRoute(target?: { runId?: string; answer?: boolean }): void + /** Open a web app page in the system browser. */ + openInBrowser(url: string): void } export interface Wf { @@ -378,7 +380,8 @@ export function WorkflowsScreen(props: ScreenProps) { { key: "a", label: "answer", run: answerFirst }, { key: "f", label: `filter: ${nav().filter}`, run: () => void setNav(cycleFilter(nav())) }, { key: "d", label: "clean up finished", run: () => void wf.actions.cleanupPending() }, - { key: "p", label: "pair browser", run: () => void wf.actions.pair() }, + ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []), + { key: "p", label: "pair a device", run: () => void wf.actions.pair() }, { key: "r", label: "refresh", run: () => void wf.actions.refresh() }, ...common, ] @@ -401,6 +404,7 @@ export function WorkflowsScreen(props: ScreenProps) { { key: "x", label: "stop unit", run: withUnit((t, u) => wf.actions.stopUnit(t, u)) }, { key: "r", label: "restart unit", run: withUnit((t, u) => wf.actions.restartUnit(t, u)) }, ]), + ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []), { key: "p", label: "parent session", run: withRun((t) => wf.actions.openTranscript(t.parentSessionID)) }, ...common, ] @@ -408,6 +412,7 @@ export function WorkflowsScreen(props: ScreenProps) { return [ { key: "↑↓", label: "scroll", run: () => moveBy(3) }, { key: "o", label: "transcript", run: openUnitTranscript }, + ...(webLink() ? [{ key: "b", label: "open in browser", run: () => wf.actions.openInBrowser(webLink()!) }] : []), ...(terminal ? [] : [ @@ -456,6 +461,13 @@ export function WorkflowsScreen(props: ScreenProps) { { title: "Back", group: "Workflows", bind: "backspace", run: goBack }, ...(props.escapeBack ? [{ title: "Back", group: "Workflows", bind: "escape", run: goBack }] : []), { title: "Answer", group: "Workflows", bind: "a", enabled: inView("any"), run: answerFirst }, + { + title: "Open in the web app", + group: "Workflows", + bind: "b", + enabled: () => !!webLink(), + run: () => void (webLink() && wf.actions.openInBrowser(webLink()!)), + }, { title: "Filter", group: "Workflows", @@ -594,6 +606,13 @@ export function WorkflowsScreen(props: ScreenProps) { } return parts.map((part) => truncate(part, Math.max(12, Math.floor(innerWidth() / parts.length) - 4))) } + // The web app page for what is on screen: the library, or the Run being looked at. + const webLink = () => { + const base = wf.state().webUrl?.replace(/\/$/, "") + if (!base) return null + const v = view() + return v.kind === "library" ? base : `${base}/runs/${v.runId}` + } const libraryCounts = () => { const all = props.entries() const live = all.filter((entry) => entry.live).length @@ -624,9 +643,21 @@ export function WorkflowsScreen(props: ScreenProps) { )} - 60}> - {libraryCounts()} - + + 60}> + {libraryCounts()} + + 50}> + + {"web "} + + {webLink()! + .replace(/^https?:\/\//, "") + .replace(/\/runs\/([0-9a-f-]{8})[0-9a-f-]*$/, "/runs/$1…")} + + + + {truncate(`! ${wf.error()}`, innerWidth())} diff --git a/packages/plugin/test/gateway-auth.test.ts b/packages/plugin/test/gateway-auth.test.ts new file mode 100644 index 0000000..dddf047 --- /dev/null +++ b/packages/plugin/test/gateway-auth.test.ts @@ -0,0 +1,60 @@ +import { afterAll, describe, expect, it } from "bun:test" +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { createTokenStore } from "../src/gateway/auth" + +const dir = await mkdtemp(path.join(os.tmpdir(), "wf-tokens-")) +afterAll(() => rm(dir, { recursive: true, force: true })) + +describe("gateway tokens", () => { + it("deleting an entry from the token file revokes it in the running store", async () => { + const file = path.join(dir, "gateway-tokens.json") + const store = await createTokenStore(file) + const kept = await store.issue("kept", ["read"]) + const removed = await store.issue("removed", ["read", "control"]) + expect(store.verify(removed.token)?.name).toBe("removed") + + const doc = JSON.parse(await readFile(file, "utf8")) as { tokens: Array<{ id: string }> } + doc.tokens = doc.tokens.filter((token) => token.id !== removed.id) + await writeFile(file, JSON.stringify(doc)) + + expect(store.verify(removed.token)).toBeNull() + expect(store.verify(kept.token)?.name).toBe("kept") + }) + + it("a file made unreadable (chmod 000) grants nothing, even for a token it held", async () => { + if (process.platform === "win32" || process.getuid?.() === 0) return + const file = path.join(dir, "locked.json") + const store = await createTokenStore(file) + const issued = await store.issue("device", ["read"]) + expect(store.verify(issued.token)?.name).toBe("device") + await chmod(file, 0o000) + expect(store.verify(issued.token)).toBeNull() + await chmod(file, 0o600) + }) + + it("issuing after an outside edit keeps the edit (a revocation is not written back)", async () => { + const file = path.join(dir, "edited.json") + const store = await createTokenStore(file) + const revoked = await store.issue("revoked", ["read"]) + await writeFile(file, JSON.stringify({ tokens: [] })) + await store.issue("new", ["read"]) + const names = (JSON.parse(await readFile(file, "utf8")) as { tokens: Array<{ name: string }> }).tokens.map( + (t) => t.name, + ) + expect(names).toEqual(["new"]) + expect(store.verify(revoked.token)).toBeNull() + }) + + it("a missing or unreadable file grants nothing", async () => { + const file = path.join(dir, "broken.json") + const store = await createTokenStore(file) + const issued = await store.issue("device", ["read"]) + await writeFile(file, "{ not json") + expect(store.verify(issued.token)).toBeNull() + await rm(file) + expect(store.verify(issued.token)).toBeNull() + }) +}) diff --git a/packages/plugin/test/live/harness.ts b/packages/plugin/test/live/harness.ts index 094fd6f..c614fc0 100644 --- a/packages/plugin/test/live/harness.ts +++ b/packages/plugin/test/live/harness.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -53,6 +53,26 @@ function freePort(): number { return port } +/** + * A config directory for the test server: the machine's global config (providers, models) without its plugins, + * so a copy of this plugin installed on the machine (say from npm, with other options) never loads beside the + * one under test. + */ +async function isolatedConfig(root: string): Promise { + const home = path.join(root, "config") + const target = path.join(home, "opencode") + await mkdir(target, { recursive: true }) + const global = path.join( + process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), + "opencode", + "opencode.json", + ) + const config = JSON.parse(await readFile(global, "utf8").catch(() => "{}")) as Record + delete config.plugins + await writeFile(path.join(target, "opencode.json"), JSON.stringify(config, null, 2)) + return home +} + export async function startLive(options: LiveOptions = {}): Promise { const root = await mkdtemp(path.join(await realTmp(), "wf-live-")) const project = path.join(root, "project") @@ -80,6 +100,7 @@ export async function startLive(options: LiveOptions = {}): Promise await mkdir(path.dirname(path.join(project, file)), { recursive: true }) await writeFile(path.join(project, file), contents) } + const configHome = await isolatedConfig(root) if (options.commit) await Bun.$`git add -A && git -c user.email=live@test -c user.name=live commit -qm fixture`.cwd(project).quiet() let output = "" @@ -91,6 +112,7 @@ export async function startLive(options: LiveOptions = {}): Promise ...process.env, OPENCODE_DB: path.join(root, "opencode.db"), XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: configHome, XDG_STATE_HOME: path.join(root, "state"), }, stdout: "pipe", diff --git a/packages/plugin/test/protocol-schemas.test.ts b/packages/plugin/test/protocol-schemas.test.ts index 63eeb76..c9dcbcd 100644 --- a/packages/plugin/test/protocol-schemas.test.ts +++ b/packages/plugin/test/protocol-schemas.test.ts @@ -11,3 +11,16 @@ test("the published protocol JSON Schemas match the zod source", () => { expect(result.stderr.toString()).toBe("") expect(result.exitCode).toBe(0) }) + +test("the OpenAPI document is self-consistent: every $ref resolves and operation ids are unique", async () => { + const { openApiDocument } = await import("../src/protocol/openapi") + const doc = openApiDocument() as { + paths: Record> + components: { schemas: Record } + } + const refs = [...JSON.stringify(doc).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)].map((m) => m[1]!) + expect(refs.length).toBeGreaterThan(10) + for (const name of refs) expect(doc.components.schemas[name]).toBeDefined() + const ids = Object.values(doc.paths).flatMap((methods) => Object.values(methods).map((op) => op.operationId)) + expect(new Set(ids).size).toBe(ids.length) +})