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 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
- ![npm](https://img.shields.io/npm/dt/remote-opencode) πŸ“¦ Used by developers worldwide β€” **800+ weekly downloads** on npm
6
-
5
+ ![npm](https://img.shields.io/npm/dt/remote-opencode) πŸ“¦ 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 | Description |
137
- |---------|-------------|
138
- | `remote-opencode` | Start the bot (shows setup guide if not configured) |
139
- | `remote-opencode setup` | Interactive setup wizard β€” configures bot token, IDs |
140
- | `remote-opencode start` | Start the Discord bot |
141
- | `remote-opencode deploy` | Deploy/update slash commands to Discord |
142
- | `remote-opencode config` | Display current configuration info |
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` | Short name for the project (e.g., `myapp`, `backend`) |
161
- | `path` | Absolute path to the project on your machine |
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 | Description |
204
- |-----------|-------------|
205
- | `branch` | Git branch name (will be sanitized) |
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 | Purpose |
368
- |------|---------|
369
- | `config.json` | Bot credentials (token, client ID, guild ID) |
370
- | `data.json` | Project paths, channel bindings, session data |
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 | Description |
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
+ }
@@ -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
- // Step 5: Invite Bot to Server
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 5: Invite Bot to Server');
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 6: Deploy Commands
185
+ // Step 7: Deploy Commands
160
186
  const shouldDeploy = await p.confirm({
161
187
  message: 'Deploy slash commands now?',
162
188
  initialValue: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-opencode",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Discord bot for remote OpenCode CLI access",
5
5
  "main": "dist/src/index.js",
6
6
  "bin": {