pi-roundtable-sandbox 0.23.1 → 0.24.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/CHANGELOG.md +27 -1
- package/README.md +157 -10
- package/examples/roundtable.config.ts +32 -0
- package/package.json +3 -3
- package/src/broker.ts +37 -6
- package/src/channel-store.ts +51 -1
- package/src/claim.ts +22 -8
- package/src/commands.ts +17 -1
- package/src/container-driver.ts +66 -7
- package/src/history.ts +52 -0
- package/src/index.ts +36 -2
- package/src/layout.ts +58 -0
- package/src/paging.ts +57 -0
- package/src/plugin.ts +10 -1
- package/src/protocol.ts +138 -0
- package/src/runtime-options.ts +185 -1
- package/src/runtime.ts +126 -29
- package/src/sandbox-turn.ts +61 -9
- package/src/skills.ts +185 -0
- package/worker/Dockerfile +3 -2
- package/worker/agent.ts +91 -14
- package/worker/files.ts +293 -0
- package/worker/memory.ts +48 -39
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## Unreleased
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.24.0] - 2026-10-29
|
|
6
|
+
|
|
7
|
+
The core peer range is now `>=0.24.0 <0.25.0`. Every new option is optional; with none set, behavior, mounts, files on disk, the worker protocol and the public API are as in 0.23.x, and `PiSandboxRuntime` is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `sandbox({ guilds })`: Discord guild ids whose every channel and thread is a sandbox channel, with no `/roundtable sandbox on`. The claim now answers the router's `owns(channel, space)`; asked without a guild (stop, start fresh, delete, background turns, other plugins) it answers for channels it has seen in a listed guild, a process-local set of at most 20,000 that drops the least recently seen. `/roundtable sandbox off` is refused in a listed guild, `on` says it is already on and `status` names the configuration. Ids are validated as snowflakes at startup. `SANDBOX.channels.owns(channel, guild?)` answers for both sources; `has` keeps meaning "enabled by command". `SandboxChannelStore` takes `{ guilds }` as its third argument and has `configured(guild)`.
|
|
12
|
+
- `sandbox({ workspace: "server" })` (`"channel"` stays the default): every sandbox channel of one guild shares one workspace (memory notes and files), while each channel keeps its own last ten exchanges in a state directory mounted at `/channel`, a third bind mount in this mode only. Turns that share a workspace run one at a time in arrival order; the turn timeout starts when a turn runs, `stop` cancels a waiting turn, `busy()` lists both; at most `maxWaiting` (default 4) turns wait behind the running one in a guild, and one past that is refused as busy. `SandboxRuntime.runTurn` takes an optional fourth argument `{ space }`. On disk (under `workspaceRoot`): `guild-<sha256("guild\0" + surface + ":" + id)>` and `state-<sha256("state\0" + channel)>`; per-channel workspaces keep their names, and switching modes migrates nothing.
|
|
13
|
+
- `sandbox({ files })`: `files_list`, `files_read`, `files_write` and `files_delete` in the sealed worker, inside `files/` of the workspace (relative paths, a symlink at any segment refused, 256 KiB per file and 32 MiB in all by default, both settable). They run in the worker and spend no broker calls.
|
|
14
|
+
- `sandbox({ skills })`: directories of skill folders (`SKILL.md` with `name` and `description` frontmatter). The system prompt ends with a skill index, and the host tool `host_read_skill({ name, path?, offset? })` pages a skill's files from the host; nothing is mounted. Startup stops on a missing directory, bad frontmatter, a duplicate name or a configured host tool named `read_skill`.
|
|
15
|
+
- `sandbox({ budgets: { promptBytes, toolResultBytes, requestBytes } })` (defaults 8,192, 4,096 and 196,608; at most 32,768, 32,768 and 196,608), and `maxCalls` and `maxOutputTokens` settable through `sandbox()`. A prompt (with the skill index) over `promptBytes` stops startup naming both sizes. Host tools get `HostToolContext.resultBytes`, the allowance their result will be cut to net of the broker wrapper; `toolResultTextBytes` and `pageText` fit a page to it. A cut tool result is marked as cut and no longer points at `memory_get`.
|
|
16
|
+
- `modelUrl` may be a cleartext `http://` URL on `127.0.0.1`, `::1` or `localhost` without `allowHttp`; any other cleartext URL still needs it.
|
|
17
|
+
- Importing history: `guildWorkspaceDir`, `channelStateDir`, `channelWorkspaceDir`, `historyFilePath`, `workspaceFilesDir`, `HISTORY_FILE`, `HISTORY_MAX_ENTRIES`, `HISTORY_ENTRY_BYTES`, `encodeHistory` and `userHistoryContent` describe the on-disk layout and the history entries the worker reads.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The worker's request trim never drops the newest tool-call group (the model would call the same tools again until the call limit): when it does not fit, each of its results is cut to a fair share of what is left and marked as cut. Startup stops when `budgets.requestBytes` cannot hold `promptBytes` + 8,192 bytes of memory notes + 49,152 bytes for the current message + 8 KiB of tool-result room, naming every size.
|
|
22
|
+
- `budgets: {}` (or one whose keys are all undefined) no longer demands a protocol-2 image; only a budget with a key set does.
|
|
23
|
+
- With `workspace: "server"` the memory tools and the system line call the shared notes server notes, shared by every channel of the server; the `channel` scope name is unchanged.
|
|
24
|
+
- A turn stopped while it waits for its workspace, or refused because the queue is full, rejects with `SandboxTurnRefused` (exported; `reason` is `"stopped"` or `"busy"`), and the claim answers it without logging an infrastructure failure.
|
|
25
|
+
- The worker image carries the label `pi-roundtable-sandbox.protocol` (2), and the Dockerfile copies the new modules (`history.ts`, `paging.ts`, `files.ts`). **Rebuild the image** when you set `workspace: "server"`, `files`, `skills` or `budgets`: startup reads the label through the container driver (`ContainerDriver.imageProtocol`, optional; `DockerContainerDriver` uses `docker image inspect`) and stops on an older image, so an old worker never writes a channel's history into a shared workspace. The README's "exactly two bind mounts" now describes both modes.
|
|
26
|
+
|
|
27
|
+
## 0.23.2
|
|
28
|
+
|
|
29
|
+
- Release in lockstep with pi-roundtable 0.23.2; no package-specific behavior changes. The core peer range stays `>=0.23.0 <0.24.0`.
|
|
4
30
|
|
|
5
31
|
## 0.23.1
|
|
6
32
|
|
package/README.md
CHANGED
|
@@ -5,12 +5,13 @@ Source lives in [`packages/sandbox`][source] in the pi-roundtable repository and
|
|
|
5
5
|
Each addressed message runs a small tool-using agent in a new Docker container with no network and no real credentials.
|
|
6
6
|
A host-side Unix-socket broker is its only way out.
|
|
7
7
|
Channel memory and recent conversation text persist in the channel's dedicated workspace between containers.
|
|
8
|
+
Opt-in options (below) can also sandbox a whole Discord server, share one workspace across its channels, and give the guest file tools, skills and larger budgets.
|
|
8
9
|
|
|
9
10
|
[source]: https://github.com/wayne930242/pi-roundtable/tree/master/packages/sandbox
|
|
10
11
|
|
|
11
12
|
## Requirements
|
|
12
13
|
|
|
13
|
-
- Bun 1.4.4 or later and pi-roundtable `>=0.
|
|
14
|
+
- Bun 1.4.4 or later and pi-roundtable `>=0.24.0 <0.25.0`.
|
|
14
15
|
- A native Linux host with a local Docker daemon and a non-root service account permitted to run Docker.
|
|
15
16
|
- The worker UID/GID match that account; root workers are refused.
|
|
16
17
|
- A trusted OpenAI-compatible, non-streaming Chat Completions endpoint supporting function tools and `max_tokens`.
|
|
@@ -83,7 +84,148 @@ These commands are guarded by the published owner-command helper and serialized
|
|
|
83
84
|
Do not disable sandbox routing in a channel that should remain a guest-only boundary.
|
|
84
85
|
`initialChannels` seeds the first state file only; subsequent starts use its saved state.
|
|
85
86
|
The `SANDBOX` service exposes `channels` and `runtime` for other trusted plugins.
|
|
86
|
-
Threads have separate channel keys and do not inherit a parent channel's sandbox mode; enable each thread explicitly through the trusted channel service if needed.
|
|
87
|
+
Threads have separate channel keys and do not inherit a parent channel's sandbox mode; enable each thread explicitly through the trusted channel service if needed, or sandbox the whole server with `guilds` (below).
|
|
88
|
+
|
|
89
|
+
## Serving a whole server
|
|
90
|
+
|
|
91
|
+
Every option in this section is optional.
|
|
92
|
+
With none set, behavior, mounts, files on disk, the worker protocol and the public API are those of 0.23.x.
|
|
93
|
+
Options marked "protocol 2" need a worker image built from this release; see [Worker image protocol](#worker-image-protocol).
|
|
94
|
+
|
|
95
|
+
| Option | Meaning |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `guilds: string[]` | Discord guild ids whose every channel and thread is a sandbox channel, with no `/roundtable sandbox on`. |
|
|
98
|
+
| `workspace: "channel" \| "server"` | `"channel"` (default): one workspace per channel. `"server"` (protocol 2): the channels of one guild share one workspace. |
|
|
99
|
+
| `files: boolean \| { maxFileBytes?, maxTotalBytes? }` | Workspace file tools in the worker (protocol 2). |
|
|
100
|
+
| `skills: string[]` | Directories of skill folders, read through `host_read_skill` (protocol 2). |
|
|
101
|
+
| `budgets: { promptBytes?, toolResultBytes?, requestBytes? }` | Byte limits for the prompt, a tool result and a model request (protocol 2). |
|
|
102
|
+
| `maxCalls`, `maxOutputTokens` | The broker's calls per turn (default 48, at most 1,000) and output tokens per model call (default 4,096, at most 32,768; a thinking model's thinking counts). |
|
|
103
|
+
|
|
104
|
+
"Bytes" are measured the way the worker measures text: `Buffer.byteLength(JSON.stringify(text))`, so a Traditional Chinese character is 3 bytes and a newline 2.
|
|
105
|
+
|
|
106
|
+
### `guilds`
|
|
107
|
+
|
|
108
|
+
The claim answers the router's `owns(channel, space)`: a channel whose guild id (the `space` Discord sets, threads included) is listed is a sandbox channel whatever the state file says, including channels and threads created after start. Nothing is stored for it.
|
|
109
|
+
Answering rules are unchanged: a non-bot, non-integration message that mentions the bot or replies to it, from an author the host's `access` rules serve.
|
|
110
|
+
The admission check and the recheck inside the queued turn both use the message's guild.
|
|
111
|
+
Direct messages and other guilds are not affected; a channel enabled by command in another guild keeps working.
|
|
112
|
+
Ids must be Discord snowflakes (17 to 20 digits); an invalid id stops startup naming `guilds`.
|
|
113
|
+
|
|
114
|
+
Operations that ask `owns(channel)` without a guild (stop, start fresh, delete, background turns, other plugins) are answered from the channels the claim has been asked about with a listed guild, kept in process: an idempotent set of at most 20,000 channels that drops the least recently seen.
|
|
115
|
+
After a restart nobody owns a configured guild's channel for those questions until its first message; nothing runs there then, so stop has nothing to stop and start fresh answers that there is nothing to reset.
|
|
116
|
+
`SANDBOX.channels.has(channel)` keeps meaning "enabled by command"; `SANDBOX.channels.owns(channel, guild?)` answers for both sources.
|
|
117
|
+
|
|
118
|
+
In a listed guild `/roundtable sandbox off` is refused ("This server is sandboxed by configuration (`guilds`); change the configuration to turn it off."), `on` answers that it is already on, and `status` says the channel is sandboxed by configuration.
|
|
119
|
+
|
|
120
|
+
### `workspace: "server"`
|
|
121
|
+
|
|
122
|
+
Every sandbox channel of one guild, listed in `guilds` or enabled by command, shares one workspace: memory notes (speaker and channel scopes) and workspace files.
|
|
123
|
+
Guilds never share, and a channel whose message carries no guild keeps its own per-channel workspace.
|
|
124
|
+
Conversation history stays per channel: each channel keeps its own last ten exchanges in its own state directory, mounted at `/channel`, so a turn's container sees its guild's workspace and its own channel's state, never another channel's history.
|
|
125
|
+
|
|
126
|
+
Under `workspaceRoot`, a guild workspace is `guild-<sha256("guild\0" + surface + ":" + id)>`, a channel's state directory is `state-<sha256("state\0" + channel)>`, and today's per-channel workspaces are unchanged (`<sha256(channel)>`).
|
|
127
|
+
Switching from `"channel"` to `"server"` migrates nothing: the old per-channel workspaces stay on disk untouched, and so do their memory and history.
|
|
128
|
+
|
|
129
|
+
Turns that share a workspace run one at a time, in arrival order, in a queue inside the runtime keyed by workspace.
|
|
130
|
+
The turn timeout starts when a turn begins running, not while it waits; `stop` cancels a waiting turn as well as a running one; `busy()` lists both; the typing indicator shows while a turn waits.
|
|
131
|
+
A second turn for the same channel still cannot start while one is running or waiting.
|
|
132
|
+
At most `maxWaiting` turns (default 4, 0 to 100) wait behind the running one in a guild's queue; one past that is refused as busy and not queued, so one guest mentioning the bot in many channels or threads cannot hold the guild's workspace for N turns of the turn timeout.
|
|
133
|
+
`SandboxRuntime.runTurn(channel, speaker, text, { space })` takes the guild as an optional fourth argument; callers that omit it get the per-channel workspace.
|
|
134
|
+
|
|
135
|
+
### `files`
|
|
136
|
+
|
|
137
|
+
`files: true` (256 KiB per file, 32 MiB under `files/`) or `files: { maxFileBytes, maxTotalBytes }` gives the guest four tools in every sandbox channel of this plugin:
|
|
138
|
+
|
|
139
|
+
- `files_list({ path? })`: files under a directory, recursively, with sizes; at most 500 entries, then a note that more exist.
|
|
140
|
+
- `files_read({ path, offset? })`: text from character `offset`, as much as the tool-result budget allows; when more follows, the first line is `[More: next offset N]`, so a cut never removes it.
|
|
141
|
+
- `files_write({ path, content })`: create or replace a UTF-8 text file; parent directories are created. A write over a limit returns an error and writes nothing.
|
|
142
|
+
- `files_delete({ path })`: delete a file or an empty directory.
|
|
143
|
+
|
|
144
|
+
Paths are relative, `/`-separated and inside `files/` of the workspace; `..`, absolute paths, empty segments, NUL and a symlink at any segment (checked with `lstat` per segment) are refused. Memory and history are not reachable through them.
|
|
145
|
+
These tools run in the worker and do not spend the broker's per-turn call budget.
|
|
146
|
+
The checks run in the worker, so they bound the tools only: a compromised worker can still write anything into its mount.
|
|
147
|
+
Keep a filesystem quota on `workspaceRoot`.
|
|
148
|
+
|
|
149
|
+
### `skills`
|
|
150
|
+
|
|
151
|
+
`skills: ["/srv/bot/skills"]` names directories that each hold skill folders: `<dir>/<name>/SKILL.md` whose frontmatter has `name` equal to the folder name and a `description`.
|
|
152
|
+
The system prompt ends with an index (one sentence telling the model to read a matching skill with `host_read_skill` before answering, then each skill's name and description).
|
|
153
|
+
`host_read_skill({ name, path?, offset? })` returns `SKILL.md`, or the file `path` inside the folder of skill `name`, paged by character offset like `files_read`; to point one skill at another's file, name that skill.
|
|
154
|
+
Paths outside the folder are refused, links that leave it too.
|
|
155
|
+
It runs on the host: skill files are never mounted into a container.
|
|
156
|
+
Startup stops on a missing directory, a folder without valid frontmatter, two skills of one name (both paths are named), or a configured host tool named `read_skill`.
|
|
157
|
+
Host tools reach the model as `host_<name>`, so a skill that names a tool must write `host_roll_dice`, not `roll_dice`.
|
|
158
|
+
|
|
159
|
+
### `budgets`
|
|
160
|
+
|
|
161
|
+
| Budget | Default | At most | Meaning |
|
|
162
|
+
| --- | --- | --- | --- |
|
|
163
|
+
| `promptBytes` | 8,192 | 32,768 | `prompt` plus the skill index. Startup stops when they exceed it, naming both sizes; nothing is cut silently. The current time, speaker memory and channel notes keep their own 4,096-byte caps outside it. |
|
|
164
|
+
| `toolResultBytes` | 4,096 | 32,768 | The per-call cap on a tool result, as the worker measures it after encoding. A cut result is marked with a note saying it was cut. |
|
|
165
|
+
| `requestBytes` | 196,608 | 196,608 | The size the worker trims each model request to, dropping the oldest history first and then the oldest complete tool-call groups, but never the newest group: when that does not fit, each of its results is cut to a fair share of what is left (small results stay whole) and marked as cut. Lower it to fit a smaller model context. |
|
|
166
|
+
|
|
167
|
+
Startup stops when `requestBytes` cannot hold `promptBytes` + 8,192 bytes of memory notes + 49,152 bytes for the largest current message the worker keeps + 8 KiB of tool-result room (the worker allows 8 tool calls per reply, and 1,024 bytes each is the least worth having), naming each size. The tool descriptions, the speaker's history and the assistant's tool calls also count against the request but are not part of the floor, so leave headroom above it; a turn whose fixed parts alone exceed `requestBytes` fails instead of looping. The example below (24,576 / 98,304 / 12,288) passes, and after eight full-size results each is cut to about 8.8 KiB.
|
|
168
|
+
|
|
169
|
+
Host tools receive the allowance their result text will be cut to as `context.resultBytes`, net of the broker's `{"text":…}` wrapper, so a paging tool can fit its page.
|
|
170
|
+
The text is encoded twice on its way into the model's context, so a newline or a quote costs four bytes; measure a candidate with `toolResultTextBytes(text)`, or let `pageText(text, offset, budget, measure)` cut a page that begins with the `[More: next offset N]` line.
|
|
171
|
+
The broker's 256 KiB body limit, the 256 KiB turn input limit, the 12 model calls per turn and the 8 tools per reply stay.
|
|
172
|
+
Without `budgets` and `skills`, an oversized `prompt` is still cut at 8,192 bytes by the worker, as before.
|
|
173
|
+
|
|
174
|
+
### A model on the host's loopback
|
|
175
|
+
|
|
176
|
+
`modelUrl` may use `http://` when its host is `127.0.0.1`, `::1` or `localhost`: the broker runs on the host, so the request never leaves it, and a local gateway needs no TLS.
|
|
177
|
+
Any other cleartext URL still needs `allowHttp`, and credentials in the URL stay refused.
|
|
178
|
+
This applies to `modelUrl` only, not to MCP servers.
|
|
179
|
+
|
|
180
|
+
### Worker image protocol
|
|
181
|
+
|
|
182
|
+
The worker image carries the label `pi-roundtable-sandbox.protocol` with the worker protocol version (2 in this release, exported as `SANDBOX_PROTOCOL`).
|
|
183
|
+
At startup the plugin reads it through the container driver (`docker image inspect`; a test driver answers directly).
|
|
184
|
+
With `workspace: "server"`, `files`, `skills` or `budgets` set, an image without version 2 stops startup with a message to rebuild it, so an old worker can never write a channel's history into the shared guild workspace.
|
|
185
|
+
A custom `ContainerDriver` that cannot report the protocol (`imageProtocol?(image)`) stops a startup that needs it.
|
|
186
|
+
Rebuild with the `docker build` command above after upgrading; the Dockerfile copies the new modules.
|
|
187
|
+
|
|
188
|
+
### A whole-server guest bot
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { sandbox } from "pi-roundtable-sandbox";
|
|
192
|
+
|
|
193
|
+
export const plugins = [sandbox({
|
|
194
|
+
image: "pi-roundtable-sandbox:local",
|
|
195
|
+
runRoot: "/run/bot/sandbox",
|
|
196
|
+
workspaceRoot: "/srv/bot/workspaces",
|
|
197
|
+
stateFile: "/srv/bot/state/channels.json",
|
|
198
|
+
model: "operator-selected-model",
|
|
199
|
+
modelUrl: "http://127.0.0.1:4000/v1/chat/completions",
|
|
200
|
+
apiKey: () => process.env.MODEL_API_KEY,
|
|
201
|
+
prompt: "You are the server's helper. Answer in the asker's language.",
|
|
202
|
+
guilds: ["900000000000000001"],
|
|
203
|
+
workspace: "server",
|
|
204
|
+
files: true,
|
|
205
|
+
skills: ["/srv/bot/skills"],
|
|
206
|
+
budgets: { promptBytes: 24_576, toolResultBytes: 12_288, requestBytes: 98_304 },
|
|
207
|
+
maxOutputTokens: 12_288,
|
|
208
|
+
tools: [/* host tools: the model sees them as host_<name> */],
|
|
209
|
+
})];
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Pair it with the host's `access` rules so every member of the guild is served at guest level, and with `agentServer: false` when the host has no agents.
|
|
213
|
+
|
|
214
|
+
### Importing old chats
|
|
215
|
+
|
|
216
|
+
A host that writes history before a channel's first turn (for example a one-shot import of old conversations) should not re-derive hashes. The package exports:
|
|
217
|
+
|
|
218
|
+
| Export | Meaning |
|
|
219
|
+
| --- | --- |
|
|
220
|
+
| `guildWorkspaceDir(workspaceRoot, surface, guild)` | The shared workspace of a guild (`workspace: "server"`). |
|
|
221
|
+
| `channelStateDir(workspaceRoot, channel)` | A channel's own state directory in `"server"` mode; its history lives here. |
|
|
222
|
+
| `channelWorkspaceDir(workspaceRoot, channel)` | A channel's own workspace (`"channel"` mode, and a message with no guild); its history lives here. |
|
|
223
|
+
| `historyFilePath(directory)`, `HISTORY_FILE` | `<directory>/history.json`. |
|
|
224
|
+
| `workspaceFilesDir(workspaceDir)` | `<workspace>/files`, where the file tools keep their files. |
|
|
225
|
+
| `encodeHistory(entries)`, `SandboxHistoryEntry` | The text of a history file for `{ role: "user" \| "assistant", content }` entries; refuses more than `HISTORY_MAX_ENTRIES` (20) entries or content over `HISTORY_ENTRY_BYTES` (8,192) bytes. |
|
|
226
|
+
| `userHistoryContent({ id, name }, message)` | The JSON text a user entry's `content` holds, as new turns write it. |
|
|
227
|
+
|
|
228
|
+
Write directories with mode `0700` and files with `0600` as the service user, history last, and do it while no turn runs in that channel.
|
|
87
229
|
|
|
88
230
|
## Allowed host tools
|
|
89
231
|
|
|
@@ -107,6 +249,7 @@ const tools: HostTool[] = [{
|
|
|
107
249
|
Pass `tools` to `sandbox()`.
|
|
108
250
|
Host tool and MCP server/tool names must match `[a-z][a-z0-9_]{0,47}`.
|
|
109
251
|
The worker exposes them as `host_clock`, while the broker accepts only `POST /tools/clock`.
|
|
252
|
+
A tool also receives `context.resultBytes`, how large its result text may be (see `budgets`).
|
|
110
253
|
The host binds `context.channel` and `context.speaker` to the current admitted turn; `context.speaker.principalId` is the principal the author was admitted as.
|
|
111
254
|
The callback signal combines the request's deadline/closure and the turn's cancellation, and aborts when either scope ends.
|
|
112
255
|
Body fields cannot change that identity, even if a guest replaces the worker or forges a broker call.
|
|
@@ -139,9 +282,10 @@ A container has:
|
|
|
139
282
|
|
|
140
283
|
- `--network none`, a read-only root filesystem, all capabilities dropped, and `no-new-privileges`.
|
|
141
284
|
- A non-root UID/GID, 512 MiB RAM with no additional swap, one CPU, 64 PIDs, and a bounded temporary filesystem.
|
|
142
|
-
- Exactly two bind mounts: its channel workspace (writable) and its current-turn broker directory (read-only).
|
|
285
|
+
- Exactly two bind mounts by default: its channel workspace (writable) and its current-turn broker directory (read-only).
|
|
286
|
+
With `workspace: "server"` there are three: the guild's shared workspace at `/workspace`, the channel's own state directory at `/channel` (writable, history only) and the broker directory; the three host directories must be separate.
|
|
143
287
|
The workspace remains executable; the threat model already assumes arbitrary container code execution and does not rely on `noexec`.
|
|
144
|
-
- No Docker socket, host home, owner workspace, auth file, host environment, host tool registry or other channel's files.
|
|
288
|
+
- No Docker socket, host home, owner workspace, auth file, host environment, host tool registry or other channel's history. With `workspace: "channel"` it sees no other channel's files either; with `"server"` it sees the guild's files and notes, which every channel of that guild shares.
|
|
145
289
|
- Only the public tool descriptions, current message and speaker, system prompt, time zone, and a dummy API key in worker code.
|
|
146
290
|
|
|
147
291
|
The worker talks to the broker over a read-only Unix-socket mount.
|
|
@@ -153,7 +297,7 @@ A short second removal covers the local create/attach race.
|
|
|
153
297
|
An unresponsive daemon or delayed create request may still leave a stopped orphan; treat daemon failures and host crashes as an operator cleanup condition.
|
|
154
298
|
Containers use `--log-driver none` to avoid unbounded daemon log files and `--pull never` to run only the image the operator already built.
|
|
155
299
|
Channel keys are hashed for workspace names and are not interpreted as paths.
|
|
156
|
-
The channel queue prevents overlapping turns; other channels run independently.
|
|
300
|
+
The channel queue prevents overlapping turns; other channels run independently, except that channels sharing a `"server"` workspace take turns, with at most `maxWaiting` (default 4) waiting behind the running one, so a guest cannot queue a guild behind N × the turn timeout.
|
|
157
301
|
|
|
158
302
|
The broker accepts only POSTs to `/model`, explicitly listed `/tools/<name>`, and explicitly listed `/mcp/<server>`.
|
|
159
303
|
Queries, extra path segments, unknown routes, hop headers, cookies, proxy headers, forwarded headers, unknown headers and non-dummy caller credentials are refused.
|
|
@@ -172,7 +316,10 @@ No broker payloads or credentials are logged.
|
|
|
172
316
|
The real model/MCP credential exists only on the host and is swapped in immediately before an upstream call.
|
|
173
317
|
This protects the credential value, not the ability to spend it through the allowed model endpoint during a turn.
|
|
174
318
|
A malicious guest can call allowed routes directly and exercise every granted capability.
|
|
175
|
-
Default bounds are 48 broker calls per turn, 256 KiB request bodies, 1 MiB upstream/tool responses, one in-flight broker call, a 60-second upstream deadline, a 120-second turn deadline, and 4,096 output tokens per model request.
|
|
319
|
+
Default bounds are 48 broker calls per turn, 256 KiB request bodies, 1 MiB upstream/tool responses, one in-flight broker call, a 60-second upstream deadline, a 120-second turn deadline, and 4,096 output tokens per model request; `maxCalls`, `maxOutputTokens` and `budgets` change them.
|
|
320
|
+
Larger budgets mean larger requests to your model and more spend per turn, so raise them only as far as the model's context needs.
|
|
321
|
+
A cleartext `modelUrl` on the host's own loopback never leaves the machine; any other cleartext endpoint needs `allowHttp` and exposes the credential on the network.
|
|
322
|
+
The file tools' per-file and total caps bound the tools only; set a filesystem quota on `workspaceRoot`, because code that compromises a worker can write anything into its mounts.
|
|
176
323
|
Each broker listener permits at most 16 connections, a ten-second initial-header deadline and a 60-second request deadline.
|
|
177
324
|
The worker itself stops after 12 model calls and eight tools per response.
|
|
178
325
|
Malformed or unavailable provider tool names are refused through a local placeholder; invalid or duplicate call IDs are normalized together with their paired replies.
|
|
@@ -186,14 +333,14 @@ An abrupt host crash can leave an isolated container behind; inspect and remove
|
|
|
186
333
|
|
|
187
334
|
`memory_set`, `memory_get`, and `memory_remove` run inside the sandbox and accept a `speaker` or `channel` scope.
|
|
188
335
|
The current speaker selects the speaker namespace; a tool argument cannot select somebody else.
|
|
189
|
-
Channel notes are shared within that workspace; other
|
|
336
|
+
Channel notes are shared within that workspace: with `workspace: "server"` that is every sandbox channel of the guild, otherwise one channel; other workspaces and host agents do not see them.
|
|
190
337
|
Entries are persisted as JSON, capped at 256 entries, 2,048 characters per value and 512 KiB total.
|
|
191
338
|
Up to ten settled user/assistant exchanges persist as channel conversation context; raw tool transcripts are not retained.
|
|
192
339
|
History entries are capped at 8 KiB of JSON-encoded text, system-prompt memory previews at 4 KiB per scope, and current user context at 48 KiB.
|
|
193
340
|
Full notes remain in the store and can be retrieved or removed by key with the memory tools.
|
|
194
|
-
Model request context stays below 192 KiB by dropping old text history or complete tool-call groups, not partial tool transcripts.
|
|
341
|
+
Model request context stays below 192 KiB (`budgets.requestBytes`) by dropping old text history or complete tool-call groups, not partial tool transcripts.
|
|
195
342
|
The worker marks truncated text; bounded tool descriptions and tool results cannot grow a later turn past the broker's body limit.
|
|
196
|
-
Treat every channel's history and memory as potentially visible to all guests in that channel.
|
|
343
|
+
Treat every channel's history and memory as potentially visible to all guests in that channel, and with `workspace: "server"` the memory and files as visible to all guests in the guild.
|
|
197
344
|
Speaker namespaces prevent accidental tool cross-talk, not access by code that compromises the shared channel container.
|
|
198
345
|
Never put confidential data in a guest workspace.
|
|
199
346
|
`startFresh` resets history on the next successful turn and preserves memory; a pending reset is process-local and does not survive host restart.
|
|
@@ -201,7 +348,7 @@ Turning mode off does not erase workspace files.
|
|
|
201
348
|
|
|
202
349
|
The default sealed mode is text-only and uses a minimal Chat Completions agent loop rather than loading a full Pi session inside the container.
|
|
203
350
|
Attachments are not downloaded; the agent is told they are unsupported.
|
|
204
|
-
It
|
|
351
|
+
It loads no Pi skills or extensions (the `skills` option reads plain skill folders on the host, never in the container) and has no shell, schedules, delegation, image tools, personas, or access to the host runtime.
|
|
205
352
|
|
|
206
353
|
## Explicit Pi/subscription mode
|
|
207
354
|
|
|
@@ -34,3 +34,35 @@ export const sandboxConfig = {
|
|
|
34
34
|
}),
|
|
35
35
|
],
|
|
36
36
|
} satisfies Pick<RoundtableConfig, "plugins">;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A whole-server guest bot: every channel and thread of one guild is sandboxed and shares one
|
|
40
|
+
* workspace, with file tools, skills and byte budgets sized to a local model's context.
|
|
41
|
+
* Build the worker image from this release first; these options need protocol 2.
|
|
42
|
+
*/
|
|
43
|
+
export const guestServerConfig = {
|
|
44
|
+
plugins: [
|
|
45
|
+
sandbox({
|
|
46
|
+
image: "pi-roundtable-sandbox:local",
|
|
47
|
+
runRoot: "/tmp/roundtable-sandbox-run",
|
|
48
|
+
workspaceRoot: join(home, ".local/share/roundtable-sandbox/workspaces"),
|
|
49
|
+
stateFile: join(
|
|
50
|
+
home,
|
|
51
|
+
".local/share/roundtable-sandbox/state/channels.json",
|
|
52
|
+
),
|
|
53
|
+
model: "operator-selected-model",
|
|
54
|
+
modelUrl: "http://127.0.0.1:4000/v1/chat/completions",
|
|
55
|
+
apiKey: () => process.env.MODEL_API_KEY,
|
|
56
|
+
guilds: ["900000000000000001"],
|
|
57
|
+
workspace: "server",
|
|
58
|
+
files: true,
|
|
59
|
+
skills: [join(home, ".local/share/roundtable-sandbox/skills")],
|
|
60
|
+
budgets: {
|
|
61
|
+
promptBytes: 24_576,
|
|
62
|
+
toolResultBytes: 12_288,
|
|
63
|
+
requestBytes: 98_304,
|
|
64
|
+
},
|
|
65
|
+
maxOutputTokens: 12_288,
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
} satisfies Pick<RoundtableConfig, "plugins">;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-sandbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": ">=1.0.0 <2",
|
|
39
|
-
"pi-roundtable": ">=0.
|
|
39
|
+
"pi-roundtable": ">=0.24.0 <0.25.0",
|
|
40
40
|
"typebox": ">=1.3.34 <2"
|
|
41
41
|
},
|
|
42
42
|
"peerDependenciesMeta": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@biomejs/biome": "2.5.15",
|
|
53
53
|
"@types/bun": "1.4.3",
|
|
54
54
|
"discord.js": "14.27.0",
|
|
55
|
-
"pi-roundtable": "0.
|
|
55
|
+
"pi-roundtable": "0.24.0",
|
|
56
56
|
"typebox": "1.3.37",
|
|
57
57
|
"typescript": "7.0.2"
|
|
58
58
|
}
|
package/src/broker.ts
CHANGED
|
@@ -5,9 +5,11 @@ import { Readable } from "node:stream";
|
|
|
5
5
|
import type { ChannelKey } from "pi-roundtable";
|
|
6
6
|
import { modelInput } from "./model-input.ts";
|
|
7
7
|
import {
|
|
8
|
+
BUDGET_DEFAULTS,
|
|
8
9
|
boundedText,
|
|
9
10
|
DUMMY_KEY,
|
|
10
11
|
isRecord,
|
|
12
|
+
TOOL_RESULT_WRAPPER_BYTES,
|
|
11
13
|
type ToolSpec,
|
|
12
14
|
validName,
|
|
13
15
|
} from "./protocol.ts";
|
|
@@ -26,11 +28,20 @@ export interface SandboxSpeaker {
|
|
|
26
28
|
name: string;
|
|
27
29
|
principalId: string;
|
|
28
30
|
}
|
|
29
|
-
|
|
31
|
+
/** The turn a host tool or a broker serves, as the host fixed it. */
|
|
32
|
+
export interface HostToolScope {
|
|
30
33
|
channel: ChannelKey;
|
|
31
34
|
speaker: SandboxSpeaker;
|
|
32
35
|
signal: AbortSignal;
|
|
33
36
|
}
|
|
37
|
+
export interface HostToolContext extends HostToolScope {
|
|
38
|
+
/**
|
|
39
|
+
* How large the tool's result text may be before the worker cuts it, net of the broker's
|
|
40
|
+
* `{"text":…}` wrapper, so a paging tool can fit its page. Measure a candidate with
|
|
41
|
+
* `toolResultTextBytes` (newlines and quotes cost four bytes each, not two).
|
|
42
|
+
*/
|
|
43
|
+
resultBytes: number;
|
|
44
|
+
}
|
|
34
45
|
/**
|
|
35
46
|
* Who a model call is made for; a credential hook can pick a token per channel, speaker or
|
|
36
47
|
* principal. A `sandbox` plugin turn always carries the principal; a `PiSandboxRuntime` turn
|
|
@@ -54,7 +65,9 @@ export interface McpServer {
|
|
|
54
65
|
tools: ToolSpec[];
|
|
55
66
|
}
|
|
56
67
|
export interface BrokerOptions {
|
|
57
|
-
context:
|
|
68
|
+
context: HostToolScope;
|
|
69
|
+
/** The worker's per-result cap (default 4,096); host tools are told what is left of it. */
|
|
70
|
+
toolResultBytes?: number;
|
|
58
71
|
model: string;
|
|
59
72
|
/** Fixed complete URL, not a guest-selected base URL. */
|
|
60
73
|
modelUrl: string;
|
|
@@ -64,7 +77,10 @@ export interface BrokerOptions {
|
|
|
64
77
|
) => Promise<string | undefined> | string | undefined;
|
|
65
78
|
tools?: readonly HostTool[];
|
|
66
79
|
mcp?: readonly McpServer[];
|
|
67
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* Allow cleartext endpoints, for local testing. A model URL on `127.0.0.1`, `::1` or
|
|
82
|
+
* `localhost` is cleartext-capable without it.
|
|
83
|
+
*/
|
|
68
84
|
allowHttp?: boolean;
|
|
69
85
|
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
|
|
70
86
|
maxCalls?: number;
|
|
@@ -88,7 +104,13 @@ const FORBIDDEN_HEADERS = new Set([
|
|
|
88
104
|
"x-forwarded-proto",
|
|
89
105
|
]);
|
|
90
106
|
|
|
91
|
-
|
|
107
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `loopbackHttp` lets the model URL use cleartext when it names the host's own loopback: the
|
|
111
|
+
* broker runs on the host, so that request never leaves it.
|
|
112
|
+
*/
|
|
113
|
+
function checkUrl(raw: string, allowHttp: boolean, loopbackHttp = false): void {
|
|
92
114
|
let url: URL;
|
|
93
115
|
try {
|
|
94
116
|
url = new URL(raw);
|
|
@@ -96,7 +118,13 @@ function checkUrl(raw: string, allowHttp: boolean): void {
|
|
|
96
118
|
throw new Error("invalid broker endpoint URL");
|
|
97
119
|
}
|
|
98
120
|
if (
|
|
99
|
-
(!allowHttp &&
|
|
121
|
+
(!allowHttp &&
|
|
122
|
+
url.protocol !== "https:" &&
|
|
123
|
+
!(
|
|
124
|
+
loopbackHttp &&
|
|
125
|
+
url.protocol === "http:" &&
|
|
126
|
+
LOOPBACK_HOSTS.has(url.hostname)
|
|
127
|
+
)) ||
|
|
100
128
|
(allowHttp && !["http:", "https:"].includes(url.protocol)) ||
|
|
101
129
|
url.username ||
|
|
102
130
|
url.password ||
|
|
@@ -114,7 +142,7 @@ export class SandboxBroker {
|
|
|
114
142
|
#active = false;
|
|
115
143
|
|
|
116
144
|
constructor(options: BrokerOptions) {
|
|
117
|
-
checkUrl(options.modelUrl, options.allowHttp ?? false);
|
|
145
|
+
checkUrl(options.modelUrl, options.allowHttp ?? false, true);
|
|
118
146
|
this.#options = options;
|
|
119
147
|
this.#remaining = options.maxCalls ?? 48;
|
|
120
148
|
if (
|
|
@@ -206,6 +234,9 @@ export class SandboxBroker {
|
|
|
206
234
|
const result = await tool.run(body, {
|
|
207
235
|
...this.#options.context,
|
|
208
236
|
signal,
|
|
237
|
+
resultBytes:
|
|
238
|
+
(this.#options.toolResultBytes ?? BUDGET_DEFAULTS.toolResultBytes) -
|
|
239
|
+
TOOL_RESULT_WRAPPER_BYTES,
|
|
209
240
|
});
|
|
210
241
|
if (
|
|
211
242
|
typeof result !== "string" ||
|
package/src/channel-store.ts
CHANGED
|
@@ -10,11 +10,35 @@ import {
|
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
11
|
import type { ChannelKey } from "pi-roundtable";
|
|
12
12
|
|
|
13
|
+
/** A Discord snowflake, which is what Discord sets as the space of a guild's channels and threads. */
|
|
14
|
+
const GUILD_ID = /^\d{17,20}$/;
|
|
15
|
+
/** Channels the store remembers asking about for a listed guild, least recently seen dropped. */
|
|
16
|
+
const REMEMBERED_CHANNELS = 20_000;
|
|
17
|
+
|
|
18
|
+
export interface SandboxChannelStoreOptions {
|
|
19
|
+
/** Discord guild ids whose every channel and thread is a sandbox channel. */
|
|
20
|
+
guilds?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/** Host-only routing state. The file is never mounted into a worker. Invalid state fails closed. */
|
|
14
24
|
export class SandboxChannelStore {
|
|
15
25
|
readonly #path: string;
|
|
16
26
|
readonly #channels: Set<ChannelKey>;
|
|
17
|
-
|
|
27
|
+
readonly #guilds: ReadonlySet<string>;
|
|
28
|
+
/** In process only: nothing is stored for a configured guild's channel. */
|
|
29
|
+
readonly #remembered = new Set<ChannelKey>();
|
|
30
|
+
constructor(
|
|
31
|
+
path: string,
|
|
32
|
+
initial: readonly ChannelKey[] = [],
|
|
33
|
+
options: SandboxChannelStoreOptions = {},
|
|
34
|
+
) {
|
|
35
|
+
for (const guild of options.guilds ?? []) {
|
|
36
|
+
if (typeof guild !== "string" || !GUILD_ID.test(guild))
|
|
37
|
+
throw new Error(
|
|
38
|
+
`guilds: ${JSON.stringify(guild)} is not a Discord guild id`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
this.#guilds = new Set(options.guilds);
|
|
18
42
|
this.#path = path;
|
|
19
43
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
20
44
|
const parent = statSync(dirname(path));
|
|
@@ -45,9 +69,35 @@ export class SandboxChannelStore {
|
|
|
45
69
|
this.#channels = new Set(raw as ChannelKey[]);
|
|
46
70
|
if (!existsSync(path)) this.#save();
|
|
47
71
|
}
|
|
72
|
+
/** Whether the channel was enabled by command; a configured guild's channels are not. */
|
|
48
73
|
has(channel: ChannelKey): boolean {
|
|
49
74
|
return this.#channels.has(channel);
|
|
50
75
|
}
|
|
76
|
+
/** Whether the guild is listed in the `guilds` option. */
|
|
77
|
+
configured(guild: string | undefined): boolean {
|
|
78
|
+
return guild !== undefined && this.#guilds.has(guild);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether the channel is a sandbox channel, enabled by command or in a listed `guild`. A
|
|
82
|
+
* channel asked about with a listed guild is remembered, so later questions that carry no
|
|
83
|
+
* guild (stop, start fresh) still find it; asking again changes nothing but its recency.
|
|
84
|
+
*/
|
|
85
|
+
owns(channel: ChannelKey, guild?: string): boolean {
|
|
86
|
+
if (this.#channels.has(channel)) return true;
|
|
87
|
+
if (guild !== undefined) {
|
|
88
|
+
// Only Discord sets a guild id; another surface's space is not one.
|
|
89
|
+
if (!channel.startsWith("discord:") || !this.#guilds.has(guild))
|
|
90
|
+
return false;
|
|
91
|
+
this.#remembered.delete(channel);
|
|
92
|
+
this.#remembered.add(channel);
|
|
93
|
+
if (this.#remembered.size > REMEMBERED_CHANNELS) {
|
|
94
|
+
const oldest = this.#remembered.values().next().value;
|
|
95
|
+
if (oldest !== undefined) this.#remembered.delete(oldest);
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return this.#remembered.has(channel);
|
|
100
|
+
}
|
|
51
101
|
list(): ChannelKey[] {
|
|
52
102
|
return [...this.#channels];
|
|
53
103
|
}
|
package/src/claim.ts
CHANGED
|
@@ -6,10 +6,12 @@ import type {
|
|
|
6
6
|
} from "pi-roundtable";
|
|
7
7
|
import { splitReply } from "pi-roundtable/kit";
|
|
8
8
|
import type { SandboxChannelStore } from "./channel-store.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { type SandboxRuntime, SandboxTurnRefused } from "./runtime.ts";
|
|
10
10
|
|
|
11
11
|
export interface SandboxClaimOptions {
|
|
12
|
-
channels
|
|
12
|
+
/** `owns` adds channels in configured guilds; without it only `has` decides. */
|
|
13
|
+
channels: Pick<SandboxChannelStore, "has"> &
|
|
14
|
+
Partial<Pick<SandboxChannelStore, "owns">>;
|
|
13
15
|
runtime: Pick<SandboxRuntime, "runTurn" | "stop" | "startFresh">;
|
|
14
16
|
surfaces: Pick<SurfacePort, "sendReply" | "startTyping">;
|
|
15
17
|
reportFailure?: (channel: ChannelKey) => void;
|
|
@@ -26,14 +28,23 @@ export function isSandboxAddress(message: InboundMessage): boolean {
|
|
|
26
28
|
|
|
27
29
|
/** Outranks the agent server's priority 100; ignored messages never fall through to host agents. */
|
|
28
30
|
export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
31
|
+
const owns = (channel: ChannelKey, space?: string): boolean =>
|
|
32
|
+
options.channels.owns
|
|
33
|
+
? options.channels.owns(channel, space)
|
|
34
|
+
: options.channels.has(channel);
|
|
35
|
+
// A message names its own guild; one without a space is a sandbox message only when its channel
|
|
36
|
+
// was enabled by command, never because the channel was remembered from an earlier question.
|
|
37
|
+
const admits = (message: InboundMessage): boolean =>
|
|
38
|
+
message.space === undefined
|
|
39
|
+
? options.channels.has(message.channel)
|
|
40
|
+
: owns(message.channel, message.space);
|
|
29
41
|
return {
|
|
30
42
|
name: "sandbox",
|
|
31
43
|
priority: 200,
|
|
32
44
|
postsInPlace: true,
|
|
33
|
-
owns
|
|
45
|
+
owns,
|
|
34
46
|
admit: (message) => {
|
|
35
|
-
if (!
|
|
36
|
-
return undefined;
|
|
47
|
+
if (!admits(message) || !isSandboxAddress(message)) return undefined;
|
|
37
48
|
// The router resolved the author; one the host's access rules serve no one by goes
|
|
38
49
|
// unanswered, as does a message whose author it could not resolve.
|
|
39
50
|
const { speaker } = message;
|
|
@@ -43,7 +54,7 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
|
43
54
|
failure: "sandbox turn failed",
|
|
44
55
|
run: async () => {
|
|
45
56
|
// A queued turn rechecks mode, so an off command cannot send it to host tools.
|
|
46
|
-
if (!
|
|
57
|
+
if (!admits(message)) return;
|
|
47
58
|
const stopTyping = options.surfaces.startTyping(message.channel);
|
|
48
59
|
try {
|
|
49
60
|
const text = `${message.reference?.text ? `Quoted message (untrusted): ${message.reference.text}\n\n` : ""}${message.text}${message.attachments.length ? "\n[Attachments are not supported in this sandbox.]" : ""}`;
|
|
@@ -55,14 +66,17 @@ export function sandboxClaim(options: SandboxClaimOptions): ChannelClaim {
|
|
|
55
66
|
principalId: speaker.principalId,
|
|
56
67
|
},
|
|
57
68
|
text,
|
|
69
|
+
{ space: message.space },
|
|
58
70
|
);
|
|
59
71
|
await options.surfaces.sendReply(message.channel, {
|
|
60
72
|
chunks: splitReply(
|
|
61
73
|
reply.ok ? reply.text : "The sandbox turn failed.",
|
|
62
74
|
),
|
|
63
75
|
});
|
|
64
|
-
} catch {
|
|
65
|
-
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A stop or a full queue is the runtime working as designed, not a failure.
|
|
78
|
+
if (!(error instanceof SandboxTurnRefused))
|
|
79
|
+
options.reportFailure?.(message.channel);
|
|
66
80
|
await options.surfaces.sendReply(message.channel, {
|
|
67
81
|
chunks: ["The sandbox turn failed or was stopped."],
|
|
68
82
|
});
|
package/src/commands.ts
CHANGED
|
@@ -11,7 +11,10 @@ import type { SandboxChannelStore } from "./channel-store.ts";
|
|
|
11
11
|
/** Owner-only /roundtable sandbox on|off|status; mode changes use the same channel queue as turns. */
|
|
12
12
|
export function sandboxCommands(
|
|
13
13
|
guard: CommandGuard,
|
|
14
|
-
channels:
|
|
14
|
+
channels: Pick<
|
|
15
|
+
SandboxChannelStore,
|
|
16
|
+
"configured" | "disable" | "enable" | "has"
|
|
17
|
+
>,
|
|
15
18
|
queue: QueuePort,
|
|
16
19
|
): InteractionContribution {
|
|
17
20
|
return {
|
|
@@ -47,8 +50,21 @@ export function sandboxCommands(
|
|
|
47
50
|
"Sandbox commands require a guild channel.",
|
|
48
51
|
);
|
|
49
52
|
const channel = `discord:${interaction.channelId}` as const;
|
|
53
|
+
const configured = channels.configured(
|
|
54
|
+
interaction.guildId ?? undefined,
|
|
55
|
+
);
|
|
50
56
|
await queue.run(channel, async () => {
|
|
51
57
|
const action = interaction.options.getSubcommand(true);
|
|
58
|
+
if (configured) {
|
|
59
|
+
await interaction.editReply(
|
|
60
|
+
action === "off"
|
|
61
|
+
? "This server is sandboxed by configuration (`guilds`); change the configuration to turn it off."
|
|
62
|
+
: action === "on"
|
|
63
|
+
? "Sandbox routing is already on: this server is sandboxed by configuration (`guilds`)."
|
|
64
|
+
: "Sandbox routing is enabled: this server is sandboxed by configuration (`guilds`).",
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
52
68
|
if (action === "on") channels.enable(channel);
|
|
53
69
|
else if (action === "off") channels.disable(channel);
|
|
54
70
|
await interaction.editReply(
|