@beunec/rivine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,423 @@
1
+ # Rivine Code, in your terminal
2
+
3
+ ```
4
+ npm install -g @beunec/rivine
5
+ cd your-project
6
+ rivine
7
+ ```
8
+
9
+ Rivine Code is Beunec's software engineering engine. On the web it works inside
10
+ an Aselius Taskspace, on files it holds in a remote sandbox. This client takes
11
+ it out of that box: the engine still runs on Beunec's infrastructure, but the
12
+ files it reads and writes are **the ones already on your disk**, in the
13
+ directory you started it in, open in your editor, tracked by your git.
14
+
15
+ Nothing is uploaded. There is no "attach your repo" step and no sync. The
16
+ engine asks for a file, this client reads it, and the answer goes back — one
17
+ file at a time, only the ones it actually opens. A 40,000-file monorepo costs
18
+ nothing to start working in, because nothing is copied anywhere.
19
+
20
+ ## What it does
21
+
22
+ - Reads, writes and edits files in your working directory
23
+ - Runs commands — your shell, your PATH, your nvm/pyenv shims
24
+ - Delegates to the same sub-agent lanes as the web engine: frontend, backend,
25
+ integration, security review, CI/CD, deploy
26
+ - Asks you a question when a decision is genuinely yours to make
27
+ - Keeps a per-project memory in `.rivine/`, so tomorrow's session knows what
28
+ yesterday's did
29
+
30
+ ## Running commands
31
+
32
+ Commands run in your own shell, in your working directory, streaming — you see
33
+ output as it is produced, not in one block when the command exits. A two-minute
34
+ install and a hung one no longer look the same.
35
+
36
+ Where the platform allows it the command gets a real terminal (a PTY), which
37
+ means `isatty` is true and tools behave the way they do when you run them by
38
+ hand: colour, progress bars, and the interactive prompts some of them open with.
39
+ If a command asks you something, type the answer and press Enter — while a
40
+ command is running your input goes to the command, not to the agent. `esc`
41
+ stops it; the agent sees the non-zero exit and decides what to do about it.
42
+
43
+ The terminal comes from `node-pty`, an optional dependency. If it cannot be
44
+ installed or a PTY cannot be allocated, commands still run and still stream —
45
+ just without a terminal. Nothing fails; `rivine status` is where you look if
46
+ you want to know which one you have. `RIVINE_NO_PTY=1` forces the pipe path.
47
+
48
+ What you see and what the model is sent are deliberately different. Escape codes
49
+ are stripped and redrawn lines are collapsed to their final state before the
50
+ output leaves your machine, so a progress bar that repainted four hundred times
51
+ costs one line rather than four hundred. This is why a real terminal does not
52
+ cost more per turn than not having one.
53
+
54
+ ## Permission modes
55
+
56
+ The engine runs on our servers. The decision about what happens to your machine
57
+ is made **here**, on your machine, before anything runs — never on our servers.
58
+
59
+ | Mode | Applies without asking | Press |
60
+ |---|---|---|
61
+ | `plan` | Reads, and shell commands that provably only read | Shift+Tab |
62
+ | `default` | Reads. Asks before any write, any command, any delete | Shift+Tab |
63
+ | `acceptEdits` | Writes inside the project. Still asks before commands | Shift+Tab |
64
+ | `bypass` | Everything. For CI | `--bypass` only |
65
+
66
+ Start in a mode with `--plan`, `-m acceptEdits`, or `--bypass`. Shift+Tab
67
+ cycles the first three during a session. **`bypass` is not in the cycle** —
68
+ you cannot arrive at it by pressing a key one time too many.
69
+
70
+ Four things ask in every mode except `bypass`, however permissive the mode
71
+ otherwise is:
72
+
73
+ - **Credentials.** `.env`, `*.pem`, `~/.ssh`, `~/.aws`, service-account JSON.
74
+ These ask on *read* too — reading one puts a live secret into a model prompt.
75
+ - **Anything outside the project.** The jail resolves symlinks before deciding,
76
+ so a link pointing out of the directory is caught rather than followed.
77
+ - **Commands that are hard to undo.** `rm -rf`, `git push`, `--force`,
78
+ `git reset --hard`, `sudo`, `npm publish`, any deploy, `terraform apply`.
79
+ - **Batch writes.** Judged by their most dangerous entry, so a safe first file
80
+ cannot clear a whole batch.
81
+
82
+ One exemption: `.rivine/` — the agent's own notebook for this project, seeded at
83
+ session start and appended to at the end of each turn. It writes without asking
84
+ (except in `plan` mode), for the same reason `.git/` does. It ships with its own
85
+ `.gitignore`.
86
+
87
+ ## Commands
88
+
89
+ ```
90
+ rivine start a session in the current directory
91
+ rivine "add tests for auth.ts" one prompt, then exit
92
+ rivine -p "..." the same, explicitly
93
+ rivine --plan start read-only
94
+ rivine -C ../other-project run against a different directory
95
+
96
+ rivine login sign in (opens your browser)
97
+ rivine login --device sign in on a machine with no browser
98
+ rivine logout remove stored credentials
99
+ rivine status who you are, where tokens live, what it points at
100
+ rivine credits open the credits page
101
+
102
+ rivine mcp list configured MCP servers
103
+ rivine mcp login <name> sign in to a remote MCP server
104
+ rivine --output-format json one JSON object on stdout, for CI
105
+ ```
106
+
107
+ In a session: type `/` and press Tab. `/help` `/mode` `/plan` `/accept`
108
+ `/status` `/diff` `/pr` `/mcp` `/credits` `/clear` `/exit`. Shift+Tab changes
109
+ permission mode; Ctrl+C quits cleanly.
110
+
111
+ `/diff` shows what changed in the working tree. `/pr` prepares a pull request
112
+ through your own `gh` CLI. It **never pushes without being asked**: on a
113
+ branch that has no upstream it stops and tells you, and `/pr push` is the
114
+ form that writes to the remote. It also refuses on `main`, and refuses if
115
+ `gh` is missing or signed out.
116
+
117
+ There is no `/compact` or `/summarize`. Context is compacted on the server as
118
+ part of how the engine runs, so there is nothing here to trigger by hand.
119
+ Opening `rivine` in a directory you have used before continues that
120
+ directory's thread — earlier turns are still in context, and the session says
121
+ so on startup.
122
+
123
+ Answers are single keys, and the keys are always printed: `y` allow, `n` skip,
124
+ `a` allow all this session, `esc` cancel. Choices are numbered — press the
125
+ number. Nothing here makes you type a word to answer a question.
126
+
127
+ `a` is offered for ordinary writes only. Credentials, paths outside the project
128
+ and destructive commands are never batchable, because those are exactly the
129
+ ones where per-operation consent still means something.
130
+
131
+ ### Without a terminal
132
+
133
+ Piped, redirected, in CI, or driven by another tool, there is no keyboard to
134
+ read — so `rivine -p "..."` switches to a plain renderer with no cursor
135
+ movement and no repaint, safe to redirect to a log.
136
+
137
+ Anything that would prompt is **refused**, not auto-approved, and the refusal
138
+ names the flag that would have allowed it. Granting write access because nobody
139
+ was watching is exactly backwards.
140
+
141
+ ```
142
+ rivine -p "run the tests and fix what fails" -m acceptEdits
143
+ rivine -p "..." --bypass # CI, on a machine you accept changes to
144
+ ```
145
+
146
+ ## Sign-in
147
+
148
+ Authorization Code with PKCE over a loopback redirect. Your browser opens to
149
+ `aselius.beunec.com/cli/authorize`, which recognises your existing session and
150
+ asks you to confirm this terminal, then redirects to `127.0.0.1:1455` — a port
151
+ only a process on your own machine can listen on.
152
+
153
+ Device code (`--device`) exists for headless machines and is not the default,
154
+ deliberately: typing a code into a browser you opened yourself gives you no way
155
+ to tell *which* terminal you are authorizing, which is exactly the shape of a
156
+ phishing attack. On a box with no browser it is the lesser risk; anywhere else
157
+ loopback is strictly better.
158
+
159
+ Tokens go in your OS keychain (macOS Keychain, GNOME Secret Service, Windows
160
+ Credential Manager). Where no keychain is reachable they fall back to
161
+ `~/.rivine/credentials.json` with mode 600, and `rivine status` tells you which
162
+ is in use. In CI, set `RIVINE_TOKEN` and neither is touched.
163
+
164
+ **It is the same Aselius account, not a second one.** Auth0 only answers "who
165
+ is this person"; your cloudhandle, your BCID and your wallet live in Beunec's
166
+ own systems and are looked up from that answer. The terminal signs in through a
167
+ different OAuth client than the website, which is why you approve it separately
168
+ — but it resolves to the same identity, the same taskspaces and the same
169
+ credits. Sign in as the account you use on aselius.beunec.com and it is
170
+ literally the same you.
171
+
172
+ `rivine status` shows a `Renews:` line. If it says `no`, your session cannot
173
+ refresh itself and ends when the token expires — sign in again when it does.
174
+ That depends on a tenant setting, not on anything you can change locally.
175
+
176
+ ## MCP servers
177
+
178
+ Rivine hosts MCP servers **from your machine**, the same way Claude Code and
179
+ Codex do — not from our cloud.
180
+
181
+ ```
182
+ rivine mcp add playwright npx @playwright/mcp@latest
183
+ rivine mcp add sentry --transport http --url https://mcp.sentry.dev/mcp --token-env SENTRY_TOKEN
184
+ rivine mcp test # connect and list what each server offers
185
+ rivine mcp list
186
+ rivine mcp remove <name>
187
+ ```
188
+
189
+ In a session, `/mcp` shows what connected and what didn't.
190
+
191
+ **Why on your machine.** A stdio server *is* a local process — `npx`, `uvx`, a
192
+ docker container, a bridge to your own database. Nothing running in our cloud
193
+ can spawn it. And for remote servers, calling from here means your tokens stay
194
+ on your machine instead of in our infrastructure, and tool results never transit
195
+ it. The engine never speaks MCP: it asks this terminal to make the call, exactly
196
+ as it asks it to read a file.
197
+
198
+ **Config.** Two files, same shape as Cursor and Claude Code, so an existing
199
+ `mcpServers` block pastes straight in:
200
+
201
+ | File | For |
202
+ |---|---|
203
+ | `<project>/.rivine/mcp.json` | servers this repo needs — committable, so teammates get them |
204
+ | `~/.rivine/mcp.json` | servers you use everywhere (`--user`) |
205
+
206
+ Project wins on a name collision. Secrets are never stored: `env` values written
207
+ as `$VAR` or `${VAR}` resolve from your real environment at connect time, and a
208
+ remote server's token is referenced by variable name (`--token-env`), never by
209
+ value.
210
+
211
+ **Permissions.** An MCP tool can read a database, drive a browser or post to an
212
+ API — closer to running a command than reading a file, so it is gated like one.
213
+ Every call asks; `plan` mode refuses them outright, because a tool's schema does
214
+ not say whether it writes. "Allow all this session" is scoped to **one server**:
215
+ approving Playwright never silently approves a database server that connects
216
+ later.
217
+
218
+ **Failures are contained.** A broken server costs one line of text, not a
219
+ startup. The others still connect and their tools are still offered.
220
+
221
+ **Only the tools a turn needs.** Rivine does not put every connected tool in
222
+ front of the model on every turn. Servers are scored against your prompt, and an
223
+ unrelated request gets none of them — asking to fix a typo does not hand the
224
+ model a browser. Naming a server always includes it (`use playwright to…`), and
225
+ a server comes in as a whole set, because a browser you can open but not drive
226
+ is worse than no browser. The session line tells you which servers were used and
227
+ which were skipped.
228
+
229
+ That is not only a cost control, though it is one — every tool definition is
230
+ tokens you pay for on every turn. It is mostly a quality control: a model
231
+ holding a capability tends to find a reason to use it.
232
+
233
+ ### Remote servers that need a sign-in
234
+
235
+ `--token-env` covers servers with a static API key. For hosted servers that
236
+ authenticate per user, there is nothing to paste — the credential does not
237
+ exist until you consent:
238
+
239
+ ```bash
240
+ rivine mcp add notion --transport http --url https://mcp.example.com/mcp
241
+ rivine mcp login notion
242
+ rivine mcp logout notion # forget it again
243
+ ```
244
+
245
+ This opens your browser, runs the standard OAuth loopback flow against the
246
+ server's **own** authorization server, and stores the token in your OS
247
+ keychain — never in `.rivine/mcp.json`, which is a file people commit. If a
248
+ server publishes no OAuth metadata the command says so rather than guessing at
249
+ endpoints.
250
+
251
+ ## In CI
252
+
253
+ ```bash
254
+ rivine --output-format json -m bypass -p "fix the failing type errors"
255
+ ```
256
+
257
+ In `json` mode **stdout carries exactly one object** and everything else —
258
+ tokens, notices, command output — goes to stderr, so the stream stays safe to
259
+ pipe into `jq`:
260
+
261
+ ```json
262
+ { "ok": true, "answer": "…", "error": null, "exit_code": 0, "files_written": ["src/a.ts"] }
263
+ ```
264
+
265
+ Exit codes are `0` success, `1` error, `2` out of credits, and `3`
266
+ nothing could be done — every operation needed approval and there was no
267
+ terminal to give it. `3` exists because that run still costs a turn: without
268
+ it the step would go green having changed nothing. The result object carries
269
+ `refused` alongside `files_written` so a workflow can tell the two apart.
270
+
271
+ There is a composite action in `action.yml`:
272
+
273
+ ```yaml
274
+ - uses: actions/checkout@v4
275
+ - uses: Beunec/rivine-action@v1 # published separately on GitHub
276
+ env:
277
+ RIVINE_TOKEN: ${{ secrets.RIVINE_TOKEN }}
278
+ with:
279
+ prompt: fix the failing type errors
280
+ ```
281
+
282
+ `mode` defaults to `bypass` because a workflow has no terminal to approve
283
+ anything on, and the headless renderer **refuses** rather than auto-approves —
284
+ so any other mode stops at the first write. Run it on a checkout you are
285
+ willing to have changed, and review the diff before merging.
286
+
287
+ One turn has to finish inside the engine's **15-minute ceiling**. Local
288
+ commands stream and are not the constraint; a single request that needs longer
289
+ than that is. Split the work across steps rather than raising the timeout.
290
+
291
+ ## What the server stores
292
+
293
+ File operations travel through a mailbox table so the engine and your terminal
294
+ can hand work back and forth. Those rows carry real source — a write payload is
295
+ the file being written.
296
+
297
+ Each session generates an **AES-256 key on your machine**. It is sent once per
298
+ turn in the request body and held in memory for the length of that turn. It is
299
+ never written to disk here and never written to storage there — so no database
300
+ we keep, and no backup, replica or restore of one, contains the material needed
301
+ to open those rows. Rows are also deleted as soon as the engine has read them.
302
+
303
+ **This is not end-to-end encryption, and we will not call it that.** The engine
304
+ has to read your code to reason about it, so plaintext exists in server memory
305
+ while a turn is running. What the session key removes is every path that goes
306
+ through storage rather than through a live process — a restored snapshot, a
307
+ backup bucket, a replica, a support query. That is the larger and longer-lived
308
+ half of the exposure, not all of it.
309
+
310
+ An older terminal that sends no key falls back to server-side encryption with
311
+ the application's own key, which is weaker in exactly the way described above.
312
+
313
+ There is no third-party security audit or SOC 2 report for this yet. When there
314
+ is one, it will be linked here rather than described.
315
+
316
+ ## Credits
317
+
318
+ The same wallet as the web app, checked by the same gate. Rivine Code needs
319
+ **$10.00** in credits to start a turn; below that the CLI says so and links to
320
+ the top-up page rather than starting work it cannot finish. `rivine status` and
321
+ the status bar show your balance.
322
+
323
+ ## Configuration
324
+
325
+ Nothing is required. Two environment variables exist for non-production
326
+ deployments:
327
+
328
+ | Variable | Default |
329
+ |---|---|
330
+ | `RIVINE_API_URL` | `https://workspace.beunec.com` |
331
+ | `RIVINE_SITE_URL` | `https://aselius.beunec.com` |
332
+ | `RIVINE_TOKEN` | unset — a pre-issued token for CI |
333
+
334
+ Auth0 parameters are fetched from `GET /api/cli/config` at sign-in, so this
335
+ package carries no tenant configuration and a client-id change never requires
336
+ anyone to reinstall.
337
+
338
+ ## How it works
339
+
340
+ ```
341
+ your terminal Rivine engine
342
+ ───────────── ─────────────
343
+ rivine ──── POST /stream ──────▶ engine starts
344
+ rivine_code needs src/app.ts
345
+ ◀─── SSE fs_request ───── (blocks on a mailbox row)
346
+ reads ./src/app.ts
347
+ ──── POST fs-result ────▶ row resolved, engine continues
348
+ ◀─── SSE tokens ──────── answer streams back
349
+ ```
350
+
351
+ The engine's file operations arrive as distinct verbs — `read`, `write`,
352
+ `edit`, `execute` — rather than as shell commands. That is what lets this
353
+ client tell a read from a write and apply the right policy, and it is why
354
+ nothing here needs Python on your machine.
355
+
356
+ The request and its answer meet in a durable row rather than in memory,
357
+ because the process holding your stream open is not the one that receives your
358
+ POST. The same rows are also readable via
359
+ `GET /api/cli/sessions/{id}/pending`, which is how a resumed turn works and why
360
+ a dropped connection mid-build is survivable rather than fatal.
361
+
362
+ ## In the terminal
363
+
364
+ Rendering is budgeted rather than best-effort, because a streaming model emits
365
+ tokens faster than any terminal can usefully repaint:
366
+
367
+ - Finished lines are committed once and never redrawn. Only the live region
368
+ below them repaints.
369
+ - Tokens flush on a fixed ~24 fps clock, so a 500 token/second stream costs
370
+ about 20 repaints per second instead of 500.
371
+ - Nothing is ever wider than your window. Paths truncate in the middle, keeping
372
+ both ends; command output keeps its tail, because that is where the error is.
373
+ - Colour degrades: TrueColor, 256, or 16, and `NO_COLOR` is honoured. Colour is
374
+ never the only signal — every line kind has its own glyph, so the transcript
375
+ reads correctly in monochrome and for colour-blind readers. `RIVINE_ASCII=1`
376
+ swaps the glyphs and wordmark for pure ASCII.
377
+ - Exits restore the cursor and raw-mode state, including the abnormal ones — a
378
+ kill, or an uncaught error. You should never need to run `reset`.
379
+
380
+ ## Your identity
381
+
382
+ The status bar shows your **cloudhandle** — the identity you recognise. Your
383
+ account key is shown only as `••••••••••••` and the CLI never receives its
384
+ value.
385
+
386
+ That is deliberate: Rivine runs inside a repository that other coding agents
387
+ also read. Anything printed here lands in terminal scrollback, and a real
388
+ account key there ends up in another agent's context window and, from there, in
389
+ some model provider's logs. Masking it at the server means there is nothing to
390
+ leak.
391
+
392
+ ## Limits
393
+
394
+ - One turn holds a server invocation open for up to 15 minutes, including time
395
+ you spend reading an approval prompt. A turn that exceeds it ends; the files
396
+ written up to that point are already on your disk.
397
+ - File reads are capped at 2 MB. Larger files are reported rather than
398
+ truncated silently.
399
+ - `ls`, `glob` and `grep` skip `node_modules`, `.git`, `dist`, `.next`,
400
+ `target`, `venv` and similar — for cost, not secrecy.
401
+ - Command output is capped at 120,000 characters, keeping the tail, because a
402
+ failing build puts its error at the end.
403
+
404
+ ## Development
405
+
406
+ ```
407
+ npm install
408
+ npm run build
409
+ npm test # jail and permission-policy tests, against real directories
410
+ npm run typecheck
411
+ ```
412
+
413
+ This package ships compiled JavaScript only; the TypeScript sources are not
414
+ redistributed. For maintainers working in the repository, the interesting
415
+ code is in `src/protocol/`: `permissions.ts` decides,
416
+ `fsExecutor.ts` acts, `runner.ts` coordinates one turn. `src/ui/` only renders
417
+ — nothing with a consequence lives there.
418
+
419
+ ---
420
+
421
+ Rivine Code CLI — licensed and owned by **Beunec Technologies, Inc.**
422
+ Proprietary software. Not open source. Use is governed by the licence
423
+ agreement accompanying it.
package/action.yml ADDED
@@ -0,0 +1,165 @@
1
+ ## Rivine Code as a workflow step.
2
+ ##
3
+ ## Composite rather than a container or a JavaScript action: the step needs the
4
+ ## repository already checked out and the toolchain the repo's own steps set up
5
+ ## (the node/python the project actually uses), and a container would give it a
6
+ ## different one.
7
+ ##
8
+ ## SECURITY — read before editing this file.
9
+ ##
10
+ ## No `${{ }}` expression may appear inside a `run:` script body. GitHub
11
+ ## expands those *before* bash sees the script, so a prompt containing `";` is
12
+ ## arbitrary shell on the runner — with RIVINE_TOKEN in the environment and,
13
+ ## on the default `bypass` mode, an engine that will not ask before running
14
+ ## commands. A `pull_request` title piped into `prompt` is attacker-controlled
15
+ ## text, which makes that a remote breakout.
16
+ ##
17
+ ## Every input is therefore passed through `env:` and read as a shell variable,
18
+ ## which is never re-parsed as script. The only `${{ }}` below are in `env:`
19
+ ## and `working-directory:` keys, where they are values rather than code.
20
+ ##
21
+ ## The permission model is not relaxed here. `mode` defaults to `bypass`
22
+ ## because a workflow has no terminal to approve anything on, and the headless
23
+ ## renderer refuses rather than auto-approves — so any other mode makes a step
24
+ ## that stops at the first write. Run this on a checkout you are willing to
25
+ ## have changed, and review what it did before you merge it.
26
+
27
+ name: Rivine Code
28
+ description: Run a Rivine Code turn against the checked-out repository.
29
+ author: Beunec Technologies, Inc.
30
+
31
+ branding:
32
+ icon: terminal
33
+ color: blue
34
+
35
+ inputs:
36
+ prompt:
37
+ description: What to ask Rivine Code to do.
38
+ required: true
39
+ mode:
40
+ description: >-
41
+ Permission mode. `bypass` is the default because there is nobody to
42
+ approve anything; every other mode refuses operations that would prompt.
43
+ required: false
44
+ default: bypass
45
+ working-directory:
46
+ description: Directory to run in. Defaults to the workspace root.
47
+ required: false
48
+ default: .
49
+ version:
50
+ description: Version of @beunec/rivine to install. Defaults to the latest.
51
+ required: false
52
+ default: latest
53
+ api-url:
54
+ description: Override the Rivine API base URL. Leave unset in normal use.
55
+ required: false
56
+
57
+ outputs:
58
+ answer:
59
+ description: The agent's final answer.
60
+ value: ${{ steps.run.outputs.answer }}
61
+ files-written:
62
+ description: JSON array of paths the turn wrote.
63
+ value: ${{ steps.run.outputs.files-written }}
64
+ result:
65
+ description: The full result object, as JSON.
66
+ value: ${{ steps.run.outputs.result }}
67
+
68
+ runs:
69
+ using: composite
70
+ steps:
71
+ - name: Install Rivine Code
72
+ shell: bash
73
+ env:
74
+ RIVINE_VERSION: ${{ inputs.version }}
75
+ run: |
76
+ set -euo pipefail
77
+
78
+ # Validated rather than quoted. This becomes part of an npm package
79
+ # specifier, and a version is a narrow grammar — anything outside it is
80
+ # someone trying to install something else.
81
+ case "$RIVINE_VERSION" in
82
+ latest|next) ;;
83
+ [0-9]*.[0-9]*.[0-9]*|[0-9]*.[0-9]*.[0-9]*-*) ;;
84
+ *)
85
+ echo "::error::Refusing version '$RIVINE_VERSION' — expected 'latest', 'next', or an exact x.y.z."
86
+ exit 1
87
+ ;;
88
+ esac
89
+
90
+ npm install --global "@beunec/rivine@$RIVINE_VERSION"
91
+
92
+ - name: Run the turn
93
+ id: run
94
+ shell: bash
95
+ working-directory: ${{ inputs.working-directory }}
96
+ env:
97
+ # Every one of these is untrusted text as far as the script is
98
+ # concerned. They are read as variables and never spliced into it.
99
+ RIVINE_PROMPT: ${{ inputs.prompt }}
100
+ RIVINE_MODE: ${{ inputs.mode }}
101
+ # Read by the CLI in place of an interactive sign-in. Store it as a
102
+ # repository secret; it is an access token for a real account with a
103
+ # real credit balance.
104
+ RIVINE_TOKEN: ${{ env.RIVINE_TOKEN }}
105
+ RIVINE_API_URL: ${{ inputs.api-url }}
106
+ run: |
107
+ set -uo pipefail
108
+
109
+ if [ -z "${RIVINE_TOKEN:-}" ]; then
110
+ echo "::error::RIVINE_TOKEN is not set. Add it to the job's env from a repository secret."
111
+ exit 1
112
+ fi
113
+ if [ -z "${RIVINE_PROMPT:-}" ]; then
114
+ echo "::error::The 'prompt' input is empty."
115
+ exit 1
116
+ fi
117
+
118
+ # An allowlist, not a quoted interpolation: a mode is one of four
119
+ # words, and anything else is a mistake worth stopping on rather than
120
+ # passing to the CLI to reject later.
121
+ case "$RIVINE_MODE" in
122
+ plan|default|acceptEdits|bypass) ;;
123
+ *)
124
+ echo "::error::Unknown mode '$RIVINE_MODE'. Use plan, default, acceptEdits or bypass."
125
+ exit 1
126
+ ;;
127
+ esac
128
+
129
+ # stdout is exactly one JSON object in this mode; the readable
130
+ # transcript and any command output go to stderr, which is what shows
131
+ # up in the workflow log.
132
+ rivine --output-format json -m "$RIVINE_MODE" -p "$RIVINE_PROMPT" > "$RUNNER_TEMP/rivine.json"
133
+ status=$?
134
+
135
+ if [ ! -s "$RUNNER_TEMP/rivine.json" ]; then
136
+ echo "::error::Rivine Code produced no result (exit $status)."
137
+ exit "${status:-1}"
138
+ fi
139
+
140
+ # A random delimiter, because the agent's answer is also untrusted
141
+ # text. A fixed marker that the answer happens to contain would let it
142
+ # close the heredoc early and write arbitrary step outputs — the same
143
+ # class of bug as the one above, one layer down.
144
+ delim="RIVINE_$(openssl rand -hex 16 2>/dev/null || date +%s%N)"
145
+
146
+ {
147
+ echo "result<<$delim"
148
+ cat "$RUNNER_TEMP/rivine.json"
149
+ echo ""
150
+ echo "$delim"
151
+ } >> "$GITHUB_OUTPUT"
152
+
153
+ export RIVINE_DELIM="$delim"
154
+ node --input-type=module -e '
155
+ import { readFileSync, appendFileSync } from "node:fs";
156
+ const r = JSON.parse(readFileSync(process.env.RUNNER_TEMP + "/rivine.json", "utf8"));
157
+ const d = process.env.RIVINE_DELIM;
158
+ appendFileSync(
159
+ process.env.GITHUB_OUTPUT,
160
+ `answer<<${d}\n${String(r.answer ?? "")}\n${d}\n` +
161
+ `files-written=${JSON.stringify(r.files_written ?? [])}\n`,
162
+ );
163
+ '
164
+
165
+ exit "$status"