remote-opencode 1.1.1 β 1.2.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 +148 -26
- package/dist/src/__tests__/auth.test.js +114 -0
- package/dist/src/cli.js +51 -1
- package/dist/src/commands/allow.js +70 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/handlers/interactionHandler.js +15 -0
- package/dist/src/handlers/messageHandler.js +3 -0
- package/dist/src/services/configStore.js +35 -2
- package/dist/src/setup/wizard.js +30 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Control your AI coding assistant from anywhere β your phone, tablet, or another computer.
|
|
4
4
|
|
|
5
|
-
 π¦ Used by developers worldwide β **
|
|
6
|
-
|
|
5
|
+
 π¦ Used by developers worldwide β **1000+ weekly downloads** on npm
|
|
7
6
|
|
|
8
7
|
<div align="center">
|
|
9
8
|
<img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
|
|
@@ -19,7 +18,6 @@
|
|
|
19
18
|
|
|
20
19
|
## How It Works
|
|
21
20
|
|
|
22
|
-
|
|
23
21
|
```
|
|
24
22
|
βββββββββββββββββββ Discord API βββββββββββββββββββ
|
|
25
23
|
β Your Phone / β ββββββββββββββββΊ β Discord Bot β
|
|
@@ -50,6 +48,7 @@ The bot runs on your development machine alongside OpenCode. When you send a com
|
|
|
50
48
|
- [CLI Commands](#cli-commands)
|
|
51
49
|
- [Discord Slash Commands](#discord-slash-commands)
|
|
52
50
|
- [Usage Workflow](#usage-workflow)
|
|
51
|
+
- [Access Control](#access-control)
|
|
53
52
|
- [Configuration](#configuration)
|
|
54
53
|
- [Troubleshooting](#troubleshooting)
|
|
55
54
|
- [Development](#development)
|
|
@@ -133,13 +132,17 @@ If you prefer manual setup or need to troubleshoot:
|
|
|
133
132
|
|
|
134
133
|
## CLI Commands
|
|
135
134
|
|
|
136
|
-
| Command
|
|
137
|
-
|
|
138
|
-
| `remote-opencode`
|
|
139
|
-
| `remote-opencode setup`
|
|
140
|
-
| `remote-opencode start`
|
|
141
|
-
| `remote-opencode deploy`
|
|
142
|
-
| `remote-opencode config`
|
|
135
|
+
| Command | Description |
|
|
136
|
+
| ------------------------------------------ | ---------------------------------------------------- |
|
|
137
|
+
| `remote-opencode` | Start the bot (shows setup guide if not configured) |
|
|
138
|
+
| `remote-opencode setup` | Interactive setup wizard β configures bot token, IDs |
|
|
139
|
+
| `remote-opencode start` | Start the Discord bot |
|
|
140
|
+
| `remote-opencode deploy` | Deploy/update slash commands to Discord |
|
|
141
|
+
| `remote-opencode config` | Display current configuration info |
|
|
142
|
+
| `remote-opencode allow add <userId>` | Add a Discord user ID to the allowlist |
|
|
143
|
+
| `remote-opencode allow remove <userId>` | Remove a Discord user ID from the allowlist |
|
|
144
|
+
| `remote-opencode allow list` | List all user IDs in the allowlist |
|
|
145
|
+
| `remote-opencode allow reset` | Clear the entire allowlist (removes access control) |
|
|
143
146
|
|
|
144
147
|
---
|
|
145
148
|
|
|
@@ -155,10 +158,10 @@ Register a local project path with an alias for easy reference.
|
|
|
155
158
|
/setpath alias:myapp path:/Users/you/projects/my-app
|
|
156
159
|
```
|
|
157
160
|
|
|
158
|
-
| Parameter | Description
|
|
159
|
-
|
|
160
|
-
| `alias`
|
|
161
|
-
| `path`
|
|
161
|
+
| Parameter | Description |
|
|
162
|
+
| --------- | ----------------------------------------------------- |
|
|
163
|
+
| `alias` | Short name for the project (e.g., `myapp`, `backend`) |
|
|
164
|
+
| `path` | Absolute path to the project on your machine |
|
|
162
165
|
|
|
163
166
|
### `/projects` β List Registered Projects
|
|
164
167
|
|
|
@@ -187,6 +190,7 @@ The main command β sends a prompt to OpenCode and streams the response.
|
|
|
187
190
|
```
|
|
188
191
|
|
|
189
192
|
**Features:**
|
|
193
|
+
|
|
190
194
|
- π§΅ **Auto-creates a thread** for each conversation
|
|
191
195
|
- β‘ **Real-time streaming** β see output as it's generated (1-second updates)
|
|
192
196
|
- βΈοΈ **Interrupt button** β stop the current task if needed
|
|
@@ -200,12 +204,13 @@ Start isolated work on a new branch with its own worktree.
|
|
|
200
204
|
/work branch:feature/dark-mode description:Implement dark mode toggle
|
|
201
205
|
```
|
|
202
206
|
|
|
203
|
-
| Parameter
|
|
204
|
-
|
|
205
|
-
| `branch`
|
|
206
|
-
| `description` | Brief description of the work
|
|
207
|
+
| Parameter | Description |
|
|
208
|
+
| ------------- | ----------------------------------- |
|
|
209
|
+
| `branch` | Git branch name (will be sanitized) |
|
|
210
|
+
| `description` | Brief description of the work |
|
|
207
211
|
|
|
208
212
|
**Features:**
|
|
213
|
+
|
|
209
214
|
- π³ Creates a new git worktree for isolated work
|
|
210
215
|
- π§΅ Opens a dedicated thread for the task
|
|
211
216
|
- ποΈ **Delete button** β removes worktree and archives thread
|
|
@@ -222,11 +227,13 @@ Enable passthrough mode in a thread to send messages directly to OpenCode withou
|
|
|
222
227
|
```
|
|
223
228
|
|
|
224
229
|
**How it works:**
|
|
230
|
+
|
|
225
231
|
1. Run `/code` in any thread to enable passthrough mode
|
|
226
232
|
2. Type messages naturally β they're sent directly to OpenCode
|
|
227
233
|
3. Run `/code` again to disable
|
|
228
234
|
|
|
229
235
|
**Example:**
|
|
236
|
+
|
|
230
237
|
```
|
|
231
238
|
You: /code
|
|
232
239
|
Bot: β
Passthrough mode enabled for this thread.
|
|
@@ -245,6 +252,7 @@ Bot: β Passthrough mode disabled.
|
|
|
245
252
|
```
|
|
246
253
|
|
|
247
254
|
**Features:**
|
|
255
|
+
|
|
248
256
|
- π± **Mobile-friendly** β no more typing slash commands on phone
|
|
249
257
|
- π§΅ **Thread-scoped** β only affects the specific thread, not the whole channel
|
|
250
258
|
- β³ **Busy indicator** β shows β³ reaction if previous task is still running
|
|
@@ -259,11 +267,13 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode`
|
|
|
259
267
|
```
|
|
260
268
|
|
|
261
269
|
**How it works:**
|
|
270
|
+
|
|
262
271
|
1. Run `/autowork` in a channel bound to a project
|
|
263
272
|
2. The setting toggles on/off for that project
|
|
264
273
|
3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000`
|
|
265
274
|
|
|
266
275
|
**Features:**
|
|
276
|
+
|
|
267
277
|
- π³ **Automatic isolation** β each session gets its own branch and worktree
|
|
268
278
|
- π± **Mobile-friendly** β no need to type `/work` with branch names
|
|
269
279
|
- ποΈ **Delete button** β removes worktree when done
|
|
@@ -283,32 +293,57 @@ Control the automated job queue for the current thread.
|
|
|
283
293
|
```
|
|
284
294
|
|
|
285
295
|
**How it works:**
|
|
296
|
+
|
|
286
297
|
1. Send multiple messages to a thread (or use `/opencode` multiple times)
|
|
287
298
|
2. If the bot is busy, it reacts with `π₯` and adds the task to the queue
|
|
288
299
|
3. Once the current job is done, the bot automatically picks up the next one
|
|
289
300
|
|
|
290
301
|
**Settings:**
|
|
302
|
+
|
|
291
303
|
- `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails.
|
|
292
304
|
- `fresh_context`: If `True` (default), the AI forgets previous chat history for each new queued task to improve performance, while maintaining the same code state.
|
|
293
305
|
|
|
294
|
-
|
|
306
|
+
### `/allow` β Manage Allowlist
|
|
307
|
+
|
|
308
|
+
Manage the user allowlist directly from Discord. This command is only available when the allowlist has already been initialized (at least one user exists).
|
|
309
|
+
|
|
310
|
+
```
|
|
311
|
+
/allow action:add user:@username
|
|
312
|
+
/allow action:remove user:@username
|
|
313
|
+
/allow action:list
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
| Parameter | Description |
|
|
317
|
+
| --------- | -------------------------------------------- |
|
|
318
|
+
| `action` | `add`, `remove`, or `list` |
|
|
319
|
+
| `user` | Target user (required for `add` and `remove`) |
|
|
295
320
|
|
|
321
|
+
**Behavior:**
|
|
322
|
+
|
|
323
|
+
- **Requires authorization** β only users already on the allowlist can use this command
|
|
324
|
+
- **Cannot remove last user** β prevents accidental lockout
|
|
325
|
+
- **Disabled when allowlist is empty** β initial setup must be done via CLI or setup wizard (see [Access Control](#access-control))
|
|
326
|
+
|
|
327
|
+
---
|
|
296
328
|
|
|
297
329
|
## Usage Workflow
|
|
298
330
|
|
|
299
331
|
### Basic Workflow
|
|
300
332
|
|
|
301
333
|
1. **Register your project:**
|
|
334
|
+
|
|
302
335
|
```
|
|
303
336
|
/setpath alias:webapp path:/home/user/my-webapp
|
|
304
337
|
```
|
|
305
338
|
|
|
306
339
|
2. **Bind to a channel:**
|
|
340
|
+
|
|
307
341
|
```
|
|
308
342
|
/use alias:webapp
|
|
309
343
|
```
|
|
310
344
|
|
|
311
345
|
3. **Start coding remotely:**
|
|
346
|
+
|
|
312
347
|
```
|
|
313
348
|
/opencode prompt:Refactor the authentication module to use JWT
|
|
314
349
|
```
|
|
@@ -344,6 +379,7 @@ Share AI coding sessions with your team:
|
|
|
344
379
|
Perfect for "setting and forgetting" several tasks:
|
|
345
380
|
|
|
346
381
|
1. **Send multiple instructions:**
|
|
382
|
+
|
|
347
383
|
```
|
|
348
384
|
You: Refactor the API
|
|
349
385
|
Bot: [Starts working]
|
|
@@ -359,15 +395,73 @@ Perfect for "setting and forgetting" several tasks:
|
|
|
359
395
|
|
|
360
396
|
---
|
|
361
397
|
|
|
398
|
+
## Access Control
|
|
399
|
+
|
|
400
|
+
remote-opencode supports an optional **user allowlist** to restrict who can interact with the bot. This is essential when your bot runs in a shared Discord server where untrusted users could otherwise execute commands on your machine.
|
|
401
|
+
|
|
402
|
+
### How It Works
|
|
403
|
+
|
|
404
|
+
- **No allowlist configured (default):** All Discord users in the server can use the bot. This preserves backward compatibility for existing installations.
|
|
405
|
+
- **Allowlist configured (1+ user IDs):** Only users whose Discord IDs are in the allowlist can use slash commands, buttons, and passthrough messages. Unauthorized users receive a rejection message.
|
|
406
|
+
|
|
407
|
+
### Setting Up Access Control
|
|
408
|
+
|
|
409
|
+
> **β οΈ SECURITY WARNING: If your bot operates in a Discord channel accessible to untrusted users, you MUST configure the allowlist before starting the bot. The initial allowlist setup can ONLY be done via the CLI or the setup wizard β NOT from Discord. This prevents unauthorized users from adding themselves to an empty allowlist.**
|
|
410
|
+
|
|
411
|
+
#### Option 1: Setup Wizard (Recommended for first-time setup)
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
remote-opencode setup
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Step 5 of the wizard prompts you to enter your Discord user ID. This becomes the first entry in the allowlist.
|
|
418
|
+
|
|
419
|
+
#### Option 2: CLI
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
# Add your Discord user ID
|
|
423
|
+
remote-opencode allow add 123456789012345678
|
|
424
|
+
|
|
425
|
+
# Verify
|
|
426
|
+
remote-opencode allow list
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Managing the Allowlist
|
|
430
|
+
|
|
431
|
+
Once at least one user is on the allowlist, authorized users can manage it from Discord:
|
|
432
|
+
|
|
433
|
+
```
|
|
434
|
+
/allow action:add user:@teammate
|
|
435
|
+
/allow action:remove user:@teammate
|
|
436
|
+
/allow action:list
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Or via CLI at any time:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
remote-opencode allow add <userId>
|
|
443
|
+
remote-opencode allow remove <userId>
|
|
444
|
+
remote-opencode allow list
|
|
445
|
+
remote-opencode allow reset # Clears entire allowlist (disables access control)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Safety Guardrails
|
|
449
|
+
|
|
450
|
+
- **Cannot remove the last user** via Discord `/allow` or CLI `allow remove` β prevents accidental lockout
|
|
451
|
+
- **`allow reset`** is the only way to fully clear the allowlist (intentional action to disable access control)
|
|
452
|
+
- **Discord `/allow` is disabled when allowlist is empty** β prevents bootstrap attacks
|
|
453
|
+
- **Config file permissions** are set to `0o600` (owner-read/write only)
|
|
454
|
+
|
|
455
|
+
---
|
|
362
456
|
|
|
363
457
|
## Configuration
|
|
364
458
|
|
|
365
459
|
All configuration is stored in `~/.remote-opencode/`:
|
|
366
460
|
|
|
367
|
-
| File
|
|
368
|
-
|
|
369
|
-
| `config.json` | Bot credentials (token, client ID, guild ID)
|
|
370
|
-
| `data.json`
|
|
461
|
+
| File | Purpose |
|
|
462
|
+
| ------------- | --------------------------------------------- |
|
|
463
|
+
| `config.json` | Bot credentials (token, client ID, guild ID) |
|
|
464
|
+
| `data.json` | Project paths, channel bindings, session data |
|
|
371
465
|
|
|
372
466
|
### config.json Structure
|
|
373
467
|
|
|
@@ -375,10 +469,13 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
375
469
|
{
|
|
376
470
|
"discordToken": "your-bot-token",
|
|
377
471
|
"clientId": "your-application-id",
|
|
378
|
-
"guildId": "your-server-id"
|
|
472
|
+
"guildId": "your-server-id",
|
|
473
|
+
"allowedUserIds": ["123456789012345678"]
|
|
379
474
|
}
|
|
380
475
|
```
|
|
381
476
|
|
|
477
|
+
> `allowedUserIds` is optional. When omitted or empty, access control is disabled and all users can use the bot.
|
|
478
|
+
|
|
382
479
|
### data.json Structure
|
|
383
480
|
|
|
384
481
|
```json
|
|
@@ -394,8 +491,8 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
394
491
|
}
|
|
395
492
|
```
|
|
396
493
|
|
|
397
|
-
| Field
|
|
398
|
-
|
|
494
|
+
| Field | Description |
|
|
495
|
+
| ------------------------- | --------------------------------------------------------- |
|
|
399
496
|
| `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees |
|
|
400
497
|
|
|
401
498
|
---
|
|
@@ -419,6 +516,7 @@ All configuration is stored in `~/.remote-opencode/`:
|
|
|
419
516
|
### "No project set for this channel"
|
|
420
517
|
|
|
421
518
|
You need to bind a project to the channel:
|
|
519
|
+
|
|
422
520
|
```
|
|
423
521
|
/setpath alias:myproject path:/path/to/project
|
|
424
522
|
/use alias:myproject
|
|
@@ -427,6 +525,7 @@ You need to bind a project to the channel:
|
|
|
427
525
|
### Commands not appearing in Discord
|
|
428
526
|
|
|
429
527
|
Slash commands can take up to an hour to propagate globally. For faster updates:
|
|
528
|
+
|
|
430
529
|
1. Kick the bot from your server
|
|
431
530
|
2. Re-invite it
|
|
432
531
|
3. Run `remote-opencode deploy`
|
|
@@ -443,6 +542,7 @@ Slash commands can take up to an hour to propagate globally. For faster updates:
|
|
|
443
542
|
### Session connection issues
|
|
444
543
|
|
|
445
544
|
The bot maintains persistent sessions. If you encounter issues:
|
|
545
|
+
|
|
446
546
|
1. Start a new thread with `/opencode` instead of continuing in an old one
|
|
447
547
|
2. Restart the bot: `remote-opencode start`
|
|
448
548
|
|
|
@@ -497,6 +597,7 @@ src/
|
|
|
497
597
|
β βββ opencode.ts # Main AI interaction command
|
|
498
598
|
β βββ code.ts # Passthrough mode toggle
|
|
499
599
|
β βββ work.ts # Worktree management
|
|
600
|
+
β βββ allow.ts # Allowlist management
|
|
500
601
|
β βββ setpath.ts # Project registration
|
|
501
602
|
β βββ projects.ts # List projects
|
|
502
603
|
β βββ use.ts # Channel binding
|
|
@@ -528,18 +629,37 @@ src/
|
|
|
528
629
|
|
|
529
630
|
See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
|
|
530
631
|
|
|
632
|
+
### [1.2.0] - 2026-02-15
|
|
633
|
+
|
|
634
|
+
#### Added
|
|
635
|
+
|
|
636
|
+
- **Owner/Admin Authentication**: User allowlist system to restrict bot access to authorized Discord users only.
|
|
637
|
+
- **`/allow` Slash Command**: Manage the allowlist directly from Discord (add, remove, list users).
|
|
638
|
+
- **CLI Allowlist Management**: `remote-opencode allow add|remove|list|reset` commands for managing access control from the terminal.
|
|
639
|
+
- **Setup Wizard Integration**: Step 5 prompts for owner Discord user ID during initial setup.
|
|
640
|
+
|
|
641
|
+
#### Security
|
|
642
|
+
|
|
643
|
+
- Initial allowlist setup is restricted to CLI and setup wizard only β prevents bootstrap attacks from Discord.
|
|
644
|
+
- Config file permissions hardened to `0o600` (owner-read/write only).
|
|
645
|
+
- Discord user ID validation enforces snowflake format (`/^\d{17,20}$/`).
|
|
646
|
+
- Cannot remove the last authorized user via Discord or CLI `remove` β prevents lockout.
|
|
647
|
+
|
|
531
648
|
### [1.1.0] - 2026-02-05
|
|
532
649
|
|
|
533
650
|
#### Added
|
|
651
|
+
|
|
534
652
|
- **Automated Message Queuing**: Added a new system to queue multiple prompts in a thread. If the bot is busy, new messages are automatically queued and processed sequentially.
|
|
535
653
|
- **Queue Management**: New `/queue` slash command suite to list, clear, pause, resume, and configure queue settings.
|
|
536
654
|
|
|
537
655
|
### [1.0.10] - 2026-02-04
|
|
538
656
|
|
|
539
657
|
#### Added
|
|
658
|
+
|
|
540
659
|
- New `/setports` slash command to configure the port range for OpenCode server instances.
|
|
541
660
|
|
|
542
661
|
#### Fixed
|
|
662
|
+
|
|
543
663
|
- Fixed Windows-specific spawning issue (targeting `opencode.cmd`).
|
|
544
664
|
- Resolved `spawn EINVAL` errors on Windows.
|
|
545
665
|
- Improved server reliability and suppressed `DEP0190` security warnings.
|
|
@@ -547,10 +667,12 @@ See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
|
|
|
547
667
|
### [1.0.9] - 2026-02-04
|
|
548
668
|
|
|
549
669
|
#### Added
|
|
670
|
+
|
|
550
671
|
- New `/model` slash command to set AI models per channel.
|
|
551
672
|
- Support for `--model` flag in OpenCode server instances.
|
|
552
673
|
|
|
553
674
|
#### Fixed
|
|
675
|
+
|
|
554
676
|
- Fixed connection timeout issues.
|
|
555
677
|
- Standardized internal communication to use `127.0.0.1`.
|
|
556
678
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
vi.mock('node:fs', () => ({
|
|
4
|
+
readFileSync: vi.fn(),
|
|
5
|
+
writeFileSync: vi.fn(),
|
|
6
|
+
existsSync: vi.fn(),
|
|
7
|
+
mkdirSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('node:os', () => ({
|
|
10
|
+
homedir: vi.fn(() => '/mock/home'),
|
|
11
|
+
}));
|
|
12
|
+
import { getAllowedUserIds, setAllowedUserIds, addAllowedUserId, removeAllowedUserId, isAuthorized, } from '../services/configStore.js';
|
|
13
|
+
function mockConfigFile(config) {
|
|
14
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
|
15
|
+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify(config));
|
|
16
|
+
}
|
|
17
|
+
describe('auth allowlist', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.resetAllMocks();
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
describe('getAllowedUserIds', () => {
|
|
25
|
+
it('should return empty array when no allowedUserIds configured', () => {
|
|
26
|
+
mockConfigFile({ bot: { discordToken: 't', clientId: 'c', guildId: 'g' } });
|
|
27
|
+
expect(getAllowedUserIds()).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it('should return configured user IDs', () => {
|
|
30
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
31
|
+
expect(getAllowedUserIds()).toEqual(['111', '222']);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('isAuthorized', () => {
|
|
35
|
+
it('should allow everyone when allowlist is empty', () => {
|
|
36
|
+
mockConfigFile({});
|
|
37
|
+
expect(isAuthorized('anyUserId')).toBe(true);
|
|
38
|
+
expect(isAuthorized('anotherUser')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('should allow only listed users when allowlist is non-empty', () => {
|
|
41
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
42
|
+
expect(isAuthorized('111')).toBe(true);
|
|
43
|
+
expect(isAuthorized('222')).toBe(true);
|
|
44
|
+
expect(isAuthorized('333')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('addAllowedUserId', () => {
|
|
48
|
+
it('should add a user to the allowlist', () => {
|
|
49
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
50
|
+
addAllowedUserId('222');
|
|
51
|
+
expect(writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('"222"'), expect.objectContaining({ encoding: 'utf-8', mode: 0o600 }));
|
|
52
|
+
});
|
|
53
|
+
it('should not duplicate an existing user', () => {
|
|
54
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
55
|
+
addAllowedUserId('111');
|
|
56
|
+
expect(writeFileSync).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
it('should create the array when adding first user', () => {
|
|
59
|
+
mockConfigFile({});
|
|
60
|
+
addAllowedUserId('111');
|
|
61
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
62
|
+
expect(writtenData.allowedUserIds).toEqual(['111']);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('removeAllowedUserId', () => {
|
|
66
|
+
it('should remove a user from the allowlist', () => {
|
|
67
|
+
mockConfigFile({ allowedUserIds: ['111', '222'] });
|
|
68
|
+
const result = removeAllowedUserId('111');
|
|
69
|
+
expect(result).toBe(true);
|
|
70
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
71
|
+
expect(writtenData.allowedUserIds).toEqual(['222']);
|
|
72
|
+
});
|
|
73
|
+
it('should return false when user is not on the allowlist', () => {
|
|
74
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
75
|
+
expect(removeAllowedUserId('999')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
it('should not remove the last remaining user', () => {
|
|
78
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
79
|
+
const result = removeAllowedUserId('111');
|
|
80
|
+
expect(result).toBe(false);
|
|
81
|
+
expect(writeFileSync).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('setAllowedUserIds', () => {
|
|
85
|
+
it('should replace the entire allowlist', () => {
|
|
86
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
87
|
+
setAllowedUserIds(['333', '444']);
|
|
88
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
89
|
+
expect(writtenData.allowedUserIds).toEqual(['333', '444']);
|
|
90
|
+
});
|
|
91
|
+
it('should clear the allowlist with empty array', () => {
|
|
92
|
+
mockConfigFile({ allowedUserIds: ['111'] });
|
|
93
|
+
setAllowedUserIds([]);
|
|
94
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
95
|
+
expect(writtenData.allowedUserIds).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('config persistence', () => {
|
|
99
|
+
it('should preserve existing config when modifying allowlist', () => {
|
|
100
|
+
mockConfigFile({
|
|
101
|
+
bot: { discordToken: 'tok', clientId: 'cid', guildId: 'gid' },
|
|
102
|
+
allowedUserIds: ['111'],
|
|
103
|
+
});
|
|
104
|
+
addAllowedUserId('222');
|
|
105
|
+
const writtenData = JSON.parse(vi.mocked(writeFileSync).mock.calls[0][1]);
|
|
106
|
+
expect(writtenData.bot).toEqual({
|
|
107
|
+
discordToken: 'tok',
|
|
108
|
+
clientId: 'cid',
|
|
109
|
+
guildId: 'gid',
|
|
110
|
+
});
|
|
111
|
+
expect(writtenData.allowedUserIds).toEqual(['111', '222']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
package/dist/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import updateNotifier from 'update-notifier';
|
|
|
7
7
|
import { runSetupWizard } from './setup/wizard.js';
|
|
8
8
|
import { deployCommands } from './setup/deploy.js';
|
|
9
9
|
import { startBot } from './bot.js';
|
|
10
|
-
import { hasBotConfig, getConfigDir } from './services/configStore.js';
|
|
10
|
+
import { hasBotConfig, getConfigDir, getAllowedUserIds, addAllowedUserId, removeAllowedUserId, setAllowedUserIds } from './services/configStore.js';
|
|
11
11
|
const require = createRequire(import.meta.url);
|
|
12
12
|
// In dev mode (src/cli.ts), package.json is one level up
|
|
13
13
|
// In production (dist/src/cli.js), package.json is two levels up
|
|
@@ -68,6 +68,56 @@ program
|
|
|
68
68
|
console.log(` Bot configured: ${hasBotConfig() ? pc.green('Yes') : pc.red('No')}`);
|
|
69
69
|
console.log();
|
|
70
70
|
});
|
|
71
|
+
const allowCmd = program.command('allow').description('Manage the bot access allowlist');
|
|
72
|
+
allowCmd
|
|
73
|
+
.command('add <userId>')
|
|
74
|
+
.description('Add a user to the allowlist')
|
|
75
|
+
.action((userId) => {
|
|
76
|
+
if (!/^\d{17,20}$/.test(userId)) {
|
|
77
|
+
console.log(pc.red('β Invalid user ID. Must be a Discord snowflake (17-20 digits).'));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
addAllowedUserId(userId);
|
|
81
|
+
console.log(pc.green(`β
User ${userId} added to allowlist.`));
|
|
82
|
+
});
|
|
83
|
+
allowCmd
|
|
84
|
+
.command('remove <userId>')
|
|
85
|
+
.description('Remove a user from the allowlist')
|
|
86
|
+
.action((userId) => {
|
|
87
|
+
const result = removeAllowedUserId(userId);
|
|
88
|
+
if (result) {
|
|
89
|
+
console.log(pc.green(`β
User ${userId} removed from allowlist.`));
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const ids = getAllowedUserIds();
|
|
93
|
+
if (ids.length <= 1) {
|
|
94
|
+
console.log(pc.red('β Cannot remove the last allowed user. Use "allow reset" to disable restrictions.'));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log(pc.red(`β User ${userId} is not on the allowlist.`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
allowCmd
|
|
102
|
+
.command('list')
|
|
103
|
+
.description('Show all allowed users')
|
|
104
|
+
.action(() => {
|
|
105
|
+
const ids = getAllowedUserIds();
|
|
106
|
+
if (ids.length === 0) {
|
|
107
|
+
console.log(pc.yellow('π No restrictions β all server members can use this bot.'));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
console.log(pc.bold(`π Allowed Users (${ids.length}):`));
|
|
111
|
+
ids.forEach(id => console.log(` β’ ${id}`));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
allowCmd
|
|
115
|
+
.command('reset')
|
|
116
|
+
.description('Clear the allowlist (unrestricted mode)')
|
|
117
|
+
.action(() => {
|
|
118
|
+
setAllowedUserIds([]);
|
|
119
|
+
console.log(pc.green('β
Allowlist cleared. All server members can now use the bot.'));
|
|
120
|
+
});
|
|
71
121
|
program
|
|
72
122
|
.action(async () => {
|
|
73
123
|
if (!hasBotConfig()) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SlashCommandBuilder, MessageFlags } from 'discord.js';
|
|
2
|
+
import { getAllowedUserIds, addAllowedUserId, removeAllowedUserId, isAuthorized } from '../services/configStore.js';
|
|
3
|
+
export const allow = {
|
|
4
|
+
data: new SlashCommandBuilder()
|
|
5
|
+
.setName('allow')
|
|
6
|
+
.setDescription('Manage the bot access allowlist')
|
|
7
|
+
.addSubcommand(sub => sub.setName('add')
|
|
8
|
+
.setDescription('Add a user to the allowlist')
|
|
9
|
+
.addUserOption(opt => opt.setName('user')
|
|
10
|
+
.setDescription('The user to allow')
|
|
11
|
+
.setRequired(true)))
|
|
12
|
+
.addSubcommand(sub => sub.setName('remove')
|
|
13
|
+
.setDescription('Remove a user from the allowlist')
|
|
14
|
+
.addUserOption(opt => opt.setName('user')
|
|
15
|
+
.setDescription('The user to remove')
|
|
16
|
+
.setRequired(true)))
|
|
17
|
+
.addSubcommand(sub => sub.setName('list')
|
|
18
|
+
.setDescription('Show all allowed users')),
|
|
19
|
+
async execute(interaction) {
|
|
20
|
+
const currentList = getAllowedUserIds();
|
|
21
|
+
if (currentList.length === 0) {
|
|
22
|
+
await interaction.reply({
|
|
23
|
+
content: 'β οΈ No allowlist configured. Use `remote-opencode allow add <userId>` or `remote-opencode setup` to set up access control first.',
|
|
24
|
+
flags: MessageFlags.Ephemeral
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
29
|
+
await interaction.reply({
|
|
30
|
+
content: 'π« You are not authorized to manage the allowlist.',
|
|
31
|
+
flags: MessageFlags.Ephemeral
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const subcommand = interaction.options.getSubcommand();
|
|
36
|
+
if (subcommand === 'add') {
|
|
37
|
+
const user = interaction.options.getUser('user', true);
|
|
38
|
+
addAllowedUserId(user.id);
|
|
39
|
+
await interaction.reply({
|
|
40
|
+
content: `β
<@${user.id}> has been added to the allowlist.`,
|
|
41
|
+
flags: MessageFlags.Ephemeral
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
else if (subcommand === 'remove') {
|
|
45
|
+
const user = interaction.options.getUser('user', true);
|
|
46
|
+
const removed = removeAllowedUserId(user.id);
|
|
47
|
+
if (!removed) {
|
|
48
|
+
const reason = currentList.length <= 1
|
|
49
|
+
? 'Cannot remove the last allowed user. Use CLI `remote-opencode allow reset` to disable restrictions.'
|
|
50
|
+
: `<@${user.id}> is not on the allowlist.`;
|
|
51
|
+
await interaction.reply({
|
|
52
|
+
content: `β ${reason}`,
|
|
53
|
+
flags: MessageFlags.Ephemeral
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
await interaction.reply({
|
|
58
|
+
content: `β
<@${user.id}> has been removed from the allowlist.`,
|
|
59
|
+
flags: MessageFlags.Ephemeral
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
else if (subcommand === 'list') {
|
|
63
|
+
const userMentions = currentList.map(id => `β’ <@${id}>`).join('\n');
|
|
64
|
+
await interaction.reply({
|
|
65
|
+
content: `π **Allowed Users** (${currentList.length}):\n${userMentions}`,
|
|
66
|
+
flags: MessageFlags.Ephemeral
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
@@ -9,6 +9,7 @@ import { autowork } from './autowork.js';
|
|
|
9
9
|
import { model } from './model.js';
|
|
10
10
|
import { setports } from './setports.js';
|
|
11
11
|
import { queue } from './queue.js';
|
|
12
|
+
import { allow } from './allow.js';
|
|
12
13
|
export const commands = new Collection();
|
|
13
14
|
commands.set(setpath.data.name, setpath);
|
|
14
15
|
commands.set(projects.data.name, projects);
|
|
@@ -20,3 +21,4 @@ commands.set(autowork.data.name, autowork);
|
|
|
20
21
|
commands.set(model.data.name, model);
|
|
21
22
|
commands.set(setports.data.name, setports);
|
|
22
23
|
commands.set(queue.data.name, queue);
|
|
24
|
+
commands.set(allow.data.name, allow);
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { MessageFlags } from 'discord.js';
|
|
2
2
|
import { commands } from '../commands/index.js';
|
|
3
3
|
import { handleButton } from './buttonHandler.js';
|
|
4
|
+
import { isAuthorized } from '../services/configStore.js';
|
|
4
5
|
export async function handleInteraction(interaction) {
|
|
5
6
|
if (interaction.isButton()) {
|
|
7
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
8
|
+
await interaction.reply({
|
|
9
|
+
content: 'π« You are not authorized to use this bot.',
|
|
10
|
+
flags: MessageFlags.Ephemeral
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
6
14
|
try {
|
|
7
15
|
await handleButton(interaction);
|
|
8
16
|
}
|
|
@@ -13,6 +21,13 @@ export async function handleInteraction(interaction) {
|
|
|
13
21
|
}
|
|
14
22
|
if (!interaction.isChatInputCommand())
|
|
15
23
|
return;
|
|
24
|
+
if (!isAuthorized(interaction.user.id)) {
|
|
25
|
+
await interaction.reply({
|
|
26
|
+
content: 'π« You are not authorized to use this bot.',
|
|
27
|
+
flags: MessageFlags.Ephemeral
|
|
28
|
+
});
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
16
31
|
const command = commands.get(interaction.commandName);
|
|
17
32
|
if (!command) {
|
|
18
33
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as dataStore from '../services/dataStore.js';
|
|
2
2
|
import { runPrompt } from '../services/executionService.js';
|
|
3
3
|
import { isBusy } from '../services/queueManager.js';
|
|
4
|
+
import { isAuthorized } from '../services/configStore.js';
|
|
4
5
|
export async function handleMessageCreate(message) {
|
|
5
6
|
if (message.author.bot)
|
|
6
7
|
return;
|
|
@@ -12,6 +13,8 @@ export async function handleMessageCreate(message) {
|
|
|
12
13
|
const threadId = channel.id;
|
|
13
14
|
if (!dataStore.isPassthroughEnabled(threadId))
|
|
14
15
|
return;
|
|
16
|
+
if (!isAuthorized(message.author.id))
|
|
17
|
+
return;
|
|
15
18
|
const parentChannelId = channel.parentId;
|
|
16
19
|
if (!parentChannelId)
|
|
17
20
|
return;
|
|
@@ -5,7 +5,7 @@ const CONFIG_DIR = join(homedir(), '.remote-opencode');
|
|
|
5
5
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
6
6
|
function ensureConfigDir() {
|
|
7
7
|
if (!existsSync(CONFIG_DIR)) {
|
|
8
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
8
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
export function getConfigDir() {
|
|
@@ -26,7 +26,7 @@ export function loadConfig() {
|
|
|
26
26
|
}
|
|
27
27
|
export function saveConfig(config) {
|
|
28
28
|
ensureConfigDir();
|
|
29
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
29
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
30
30
|
}
|
|
31
31
|
export function getBotConfig() {
|
|
32
32
|
return loadConfig().bot;
|
|
@@ -53,3 +53,36 @@ export function clearBotConfig() {
|
|
|
53
53
|
delete config.bot;
|
|
54
54
|
saveConfig(config);
|
|
55
55
|
}
|
|
56
|
+
export function getAllowedUserIds() {
|
|
57
|
+
return loadConfig().allowedUserIds ?? [];
|
|
58
|
+
}
|
|
59
|
+
export function setAllowedUserIds(ids) {
|
|
60
|
+
const config = loadConfig();
|
|
61
|
+
config.allowedUserIds = ids;
|
|
62
|
+
saveConfig(config);
|
|
63
|
+
}
|
|
64
|
+
export function addAllowedUserId(id) {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
const current = config.allowedUserIds ?? [];
|
|
67
|
+
if (!current.includes(id)) {
|
|
68
|
+
config.allowedUserIds = [...current, id];
|
|
69
|
+
saveConfig(config);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function removeAllowedUserId(id) {
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
const current = config.allowedUserIds ?? [];
|
|
75
|
+
if (!current.includes(id))
|
|
76
|
+
return false;
|
|
77
|
+
if (current.length <= 1)
|
|
78
|
+
return false; // prevent removing last user
|
|
79
|
+
config.allowedUserIds = current.filter(uid => uid !== id);
|
|
80
|
+
saveConfig(config);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
export function isAuthorized(userId) {
|
|
84
|
+
const ids = getAllowedUserIds();
|
|
85
|
+
if (ids.length === 0)
|
|
86
|
+
return true; // no restriction
|
|
87
|
+
return ids.includes(userId);
|
|
88
|
+
}
|
package/dist/src/setup/wizard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import open from 'open';
|
|
4
|
-
import { setBotConfig, getBotConfig, hasBotConfig } from '../services/configStore.js';
|
|
4
|
+
import { setBotConfig, getBotConfig, hasBotConfig, addAllowedUserId } from '../services/configStore.js';
|
|
5
5
|
import { deployCommands } from './deploy.js';
|
|
6
6
|
const DISCORD_DEV_URL = 'https://discord.com/developers/applications';
|
|
7
7
|
const BOT_PERMISSIONS = '2147534848';
|
|
@@ -27,6 +27,13 @@ function validateGuildId(value) {
|
|
|
27
27
|
return 'Invalid format (should be 17-20 digits)';
|
|
28
28
|
return undefined;
|
|
29
29
|
}
|
|
30
|
+
function validateUserId(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (!/^\d{17,20}$/.test(value))
|
|
34
|
+
return 'Invalid format (should be 17-20 digits)';
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
30
37
|
function generateInviteUrl(clientId) {
|
|
31
38
|
const url = new URL('https://discord.com/api/oauth2/authorize');
|
|
32
39
|
url.searchParams.set('client_id', clientId);
|
|
@@ -123,6 +130,22 @@ export async function runSetupWizard() {
|
|
|
123
130
|
p.cancel('Setup cancelled.');
|
|
124
131
|
process.exit(0);
|
|
125
132
|
}
|
|
133
|
+
// Step 5: Set Bot Owner
|
|
134
|
+
p.note(`Restrict who can use this bot by setting an owner.\n\n` +
|
|
135
|
+
`1. In Discord, right-click ${pc.bold('YOUR profile')}\n` +
|
|
136
|
+
`2. Click ${pc.bold('"Copy User ID"')}\n\n` +
|
|
137
|
+
`${pc.dim('(Requires Developer Mode β same setting as Step 4)')}\n` +
|
|
138
|
+
`${pc.dim('Leave blank to allow everyone)')}`, 'Step 5: Set Bot Owner (Optional)');
|
|
139
|
+
const ownerId = await p.text({
|
|
140
|
+
message: 'Enter your Discord User ID (leave blank to allow everyone):',
|
|
141
|
+
placeholder: 'e.g., 1234567890123456789',
|
|
142
|
+
defaultValue: '',
|
|
143
|
+
validate: validateUserId,
|
|
144
|
+
});
|
|
145
|
+
if (p.isCancel(ownerId)) {
|
|
146
|
+
p.cancel('Setup cancelled.');
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
126
149
|
// Save configuration
|
|
127
150
|
const s = p.spinner();
|
|
128
151
|
s.start('Saving configuration...');
|
|
@@ -132,12 +155,15 @@ export async function runSetupWizard() {
|
|
|
132
155
|
guildId: guildId,
|
|
133
156
|
});
|
|
134
157
|
s.stop('Configuration saved!');
|
|
135
|
-
|
|
158
|
+
if (ownerId && ownerId.length > 0) {
|
|
159
|
+
addAllowedUserId(ownerId);
|
|
160
|
+
}
|
|
161
|
+
// Step 6: Invite Bot to Server
|
|
136
162
|
const inviteUrl = generateInviteUrl(clientId);
|
|
137
163
|
p.note(`We'll open the bot invite page in your browser.\n\n` +
|
|
138
164
|
`1. Select your server\n` +
|
|
139
165
|
`2. Click ${pc.bold('"Authorize"')}\n\n` +
|
|
140
|
-
`${pc.dim('URL: ' + inviteUrl)}`, 'Step
|
|
166
|
+
`${pc.dim('URL: ' + inviteUrl)}`, 'Step 6: Invite Bot to Server');
|
|
141
167
|
const openInvite = await p.text({
|
|
142
168
|
message: `Press ${pc.cyan('Enter')} to open the invite page...`,
|
|
143
169
|
placeholder: 'Press Enter',
|
|
@@ -156,7 +182,7 @@ export async function runSetupWizard() {
|
|
|
156
182
|
p.cancel('Setup cancelled.');
|
|
157
183
|
process.exit(0);
|
|
158
184
|
}
|
|
159
|
-
// Step
|
|
185
|
+
// Step 7: Deploy Commands
|
|
160
186
|
const shouldDeploy = await p.confirm({
|
|
161
187
|
message: 'Deploy slash commands now?',
|
|
162
188
|
initialValue: true,
|