remote-opencode 1.0.10 β†’ 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,6 +2,8 @@
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 β€” **1000+ weekly downloads** on npm
6
+
5
7
  <div align="center">
6
8
  <img width="1024" alt="Gemini_Generated_Image_47d5gq47d5gq47d5" src="https://github.com/user-attachments/assets/1defa11d-6195-4a9c-956b-4f87470f6393" />
7
9
  </div>
@@ -16,7 +18,6 @@
16
18
 
17
19
  ## How It Works
18
20
 
19
-
20
21
  ```
21
22
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Discord API β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
22
23
  β”‚ Your Phone / β”‚ ◄──────────────► β”‚ Discord Bot β”‚
@@ -47,6 +48,7 @@ The bot runs on your development machine alongside OpenCode. When you send a com
47
48
  - [CLI Commands](#cli-commands)
48
49
  - [Discord Slash Commands](#discord-slash-commands)
49
50
  - [Usage Workflow](#usage-workflow)
51
+ - [Access Control](#access-control)
50
52
  - [Configuration](#configuration)
51
53
  - [Troubleshooting](#troubleshooting)
52
54
  - [Development](#development)
@@ -130,13 +132,17 @@ If you prefer manual setup or need to troubleshoot:
130
132
 
131
133
  ## CLI Commands
132
134
 
133
- | Command | Description |
134
- |---------|-------------|
135
- | `remote-opencode` | Start the bot (shows setup guide if not configured) |
136
- | `remote-opencode setup` | Interactive setup wizard β€” configures bot token, IDs |
137
- | `remote-opencode start` | Start the Discord bot |
138
- | `remote-opencode deploy` | Deploy/update slash commands to Discord |
139
- | `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) |
140
146
 
141
147
  ---
142
148
 
@@ -152,10 +158,10 @@ Register a local project path with an alias for easy reference.
152
158
  /setpath alias:myapp path:/Users/you/projects/my-app
153
159
  ```
154
160
 
155
- | Parameter | Description |
156
- |-----------|-------------|
157
- | `alias` | Short name for the project (e.g., `myapp`, `backend`) |
158
- | `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 |
159
165
 
160
166
  ### `/projects` β€” List Registered Projects
161
167
 
@@ -184,6 +190,7 @@ The main command β€” sends a prompt to OpenCode and streams the response.
184
190
  ```
185
191
 
186
192
  **Features:**
193
+
187
194
  - 🧡 **Auto-creates a thread** for each conversation
188
195
  - ⚑ **Real-time streaming** β€” see output as it's generated (1-second updates)
189
196
  - ⏸️ **Interrupt button** β€” stop the current task if needed
@@ -197,12 +204,13 @@ Start isolated work on a new branch with its own worktree.
197
204
  /work branch:feature/dark-mode description:Implement dark mode toggle
198
205
  ```
199
206
 
200
- | Parameter | Description |
201
- |-----------|-------------|
202
- | `branch` | Git branch name (will be sanitized) |
203
- | `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 |
204
211
 
205
212
  **Features:**
213
+
206
214
  - 🌳 Creates a new git worktree for isolated work
207
215
  - 🧡 Opens a dedicated thread for the task
208
216
  - πŸ—‘οΈ **Delete button** β€” removes worktree and archives thread
@@ -219,11 +227,13 @@ Enable passthrough mode in a thread to send messages directly to OpenCode withou
219
227
  ```
220
228
 
221
229
  **How it works:**
230
+
222
231
  1. Run `/code` in any thread to enable passthrough mode
223
232
  2. Type messages naturally β€” they're sent directly to OpenCode
224
233
  3. Run `/code` again to disable
225
234
 
226
235
  **Example:**
236
+
227
237
  ```
228
238
  You: /code
229
239
  Bot: βœ… Passthrough mode enabled for this thread.
@@ -242,6 +252,7 @@ Bot: ❌ Passthrough mode disabled.
242
252
  ```
243
253
 
244
254
  **Features:**
255
+
245
256
  - πŸ“± **Mobile-friendly** β€” no more typing slash commands on phone
246
257
  - 🧡 **Thread-scoped** β€” only affects the specific thread, not the whole channel
247
258
  - ⏳ **Busy indicator** β€” shows ⏳ reaction if previous task is still running
@@ -256,11 +267,13 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode`
256
267
  ```
257
268
 
258
269
  **How it works:**
270
+
259
271
  1. Run `/autowork` in a channel bound to a project
260
272
  2. The setting toggles on/off for that project
261
273
  3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000`
262
274
 
263
275
  **Features:**
276
+
264
277
  - 🌳 **Automatic isolation** β€” each session gets its own branch and worktree
265
278
  - πŸ“± **Mobile-friendly** β€” no need to type `/work` with branch names
266
279
  - πŸ—‘οΈ **Delete button** β€” removes worktree when done
@@ -280,32 +293,57 @@ Control the automated job queue for the current thread.
280
293
  ```
281
294
 
282
295
  **How it works:**
296
+
283
297
  1. Send multiple messages to a thread (or use `/opencode` multiple times)
284
298
  2. If the bot is busy, it reacts with `πŸ“₯` and adds the task to the queue
285
299
  3. Once the current job is done, the bot automatically picks up the next one
286
300
 
287
301
  **Settings:**
302
+
288
303
  - `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails.
289
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.
290
305
 
291
- ---
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`) |
320
+
321
+ **Behavior:**
292
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
+ ---
293
328
 
294
329
  ## Usage Workflow
295
330
 
296
331
  ### Basic Workflow
297
332
 
298
333
  1. **Register your project:**
334
+
299
335
  ```
300
336
  /setpath alias:webapp path:/home/user/my-webapp
301
337
  ```
302
338
 
303
339
  2. **Bind to a channel:**
340
+
304
341
  ```
305
342
  /use alias:webapp
306
343
  ```
307
344
 
308
345
  3. **Start coding remotely:**
346
+
309
347
  ```
310
348
  /opencode prompt:Refactor the authentication module to use JWT
311
349
  ```
@@ -341,6 +379,7 @@ Share AI coding sessions with your team:
341
379
  Perfect for "setting and forgetting" several tasks:
342
380
 
343
381
  1. **Send multiple instructions:**
382
+
344
383
  ```
345
384
  You: Refactor the API
346
385
  Bot: [Starts working]
@@ -356,15 +395,73 @@ Perfect for "setting and forgetting" several tasks:
356
395
 
357
396
  ---
358
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
+ ---
359
456
 
360
457
  ## Configuration
361
458
 
362
459
  All configuration is stored in `~/.remote-opencode/`:
363
460
 
364
- | File | Purpose |
365
- |------|---------|
366
- | `config.json` | Bot credentials (token, client ID, guild ID) |
367
- | `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 |
368
465
 
369
466
  ### config.json Structure
370
467
 
@@ -372,10 +469,13 @@ All configuration is stored in `~/.remote-opencode/`:
372
469
  {
373
470
  "discordToken": "your-bot-token",
374
471
  "clientId": "your-application-id",
375
- "guildId": "your-server-id"
472
+ "guildId": "your-server-id",
473
+ "allowedUserIds": ["123456789012345678"]
376
474
  }
377
475
  ```
378
476
 
477
+ > `allowedUserIds` is optional. When omitted or empty, access control is disabled and all users can use the bot.
478
+
379
479
  ### data.json Structure
380
480
 
381
481
  ```json
@@ -391,8 +491,8 @@ All configuration is stored in `~/.remote-opencode/`:
391
491
  }
392
492
  ```
393
493
 
394
- | Field | Description |
395
- |-------|-------------|
494
+ | Field | Description |
495
+ | ------------------------- | --------------------------------------------------------- |
396
496
  | `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees |
397
497
 
398
498
  ---
@@ -416,6 +516,7 @@ All configuration is stored in `~/.remote-opencode/`:
416
516
  ### "No project set for this channel"
417
517
 
418
518
  You need to bind a project to the channel:
519
+
419
520
  ```
420
521
  /setpath alias:myproject path:/path/to/project
421
522
  /use alias:myproject
@@ -424,6 +525,7 @@ You need to bind a project to the channel:
424
525
  ### Commands not appearing in Discord
425
526
 
426
527
  Slash commands can take up to an hour to propagate globally. For faster updates:
528
+
427
529
  1. Kick the bot from your server
428
530
  2. Re-invite it
429
531
  3. Run `remote-opencode deploy`
@@ -440,6 +542,7 @@ Slash commands can take up to an hour to propagate globally. For faster updates:
440
542
  ### Session connection issues
441
543
 
442
544
  The bot maintains persistent sessions. If you encounter issues:
545
+
443
546
  1. Start a new thread with `/opencode` instead of continuing in an old one
444
547
  2. Restart the bot: `remote-opencode start`
445
548
 
@@ -494,6 +597,7 @@ src/
494
597
  β”‚ β”œβ”€β”€ opencode.ts # Main AI interaction command
495
598
  β”‚ β”œβ”€β”€ code.ts # Passthrough mode toggle
496
599
  β”‚ β”œβ”€β”€ work.ts # Worktree management
600
+ β”‚ β”œβ”€β”€ allow.ts # Allowlist management
497
601
  β”‚ β”œβ”€β”€ setpath.ts # Project registration
498
602
  β”‚ β”œβ”€β”€ projects.ts # List projects
499
603
  β”‚ └── use.ts # Channel binding
@@ -525,18 +629,37 @@ src/
525
629
 
526
630
  See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
527
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
+
528
648
  ### [1.1.0] - 2026-02-05
529
649
 
530
650
  #### Added
651
+
531
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.
532
653
  - **Queue Management**: New `/queue` slash command suite to list, clear, pause, resume, and configure queue settings.
533
654
 
534
655
  ### [1.0.10] - 2026-02-04
535
656
 
536
657
  #### Added
658
+
537
659
  - New `/setports` slash command to configure the port range for OpenCode server instances.
538
660
 
539
661
  #### Fixed
662
+
540
663
  - Fixed Windows-specific spawning issue (targeting `opencode.cmd`).
541
664
  - Resolved `spawn EINVAL` errors on Windows.
542
665
  - Improved server reliability and suppressed `DEP0190` security warnings.
@@ -544,10 +667,12 @@ See [CHANGELOG.md](CHANGELOG.md) for a full history of changes.
544
667
  ### [1.0.9] - 2026-02-04
545
668
 
546
669
  #### Added
670
+
547
671
  - New `/model` slash command to set AI models per channel.
548
672
  - Support for `--model` flag in OpenCode server instances.
549
673
 
550
674
  #### Fixed
675
+
551
676
  - Fixed connection timeout issues.
552
677
  - Standardized internal communication to use `127.0.0.1`.
553
678
 
@@ -561,10 +686,4 @@ MIT
561
686
 
562
687
  ## Contributing
563
688
 
564
- Contributions are welcome! Please feel free to submit a Pull Request.
565
-
566
- 1. Fork the repository
567
- 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
568
- 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
569
- 4. Push to the branch (`git push origin feature/amazing-feature`)
570
- 5. Open a Pull Request
689
+ Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a Pull Request.
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi } from '../utils/messageFormatter.js';
2
+ import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi, buildContextHeader } from '../utils/messageFormatter.js';
3
3
  describe('messageFormatter', () => {
4
4
  describe('stripAnsi', () => {
5
5
  it('should remove ANSI escape codes', () => {
@@ -63,6 +63,20 @@ describe('messageFormatter', () => {
63
63
  expect(accumulateText('', 'Hello')).toBe('Hello');
64
64
  });
65
65
  });
66
+ describe('buildContextHeader', () => {
67
+ it('should format branch name and model name', () => {
68
+ const result = buildContextHeader('feature/dark-mode', 'claude-sonnet-4-20250514');
69
+ expect(result).toBe('🌿 `feature/dark-mode` Β· πŸ€– `claude-sonnet-4-20250514`');
70
+ });
71
+ it('should handle default model', () => {
72
+ const result = buildContextHeader('main', 'default');
73
+ expect(result).toBe('🌿 `main` Β· πŸ€– `default`');
74
+ });
75
+ it('should handle auto-generated branch names', () => {
76
+ const result = buildContextHeader('auto/abc12345-1738600000000', 'default');
77
+ expect(result).toBe('🌿 `auto/abc12345-1738600000000` Β· πŸ€– `default`');
78
+ });
79
+ });
66
80
  describe('formatOutput (existing functionality)', () => {
67
81
  it('should work for OpenCode JSON output with newlines preserved', () => {
68
82
  const buffer = JSON.stringify({ type: 'text', part: { text: 'Hello' } }) + '\n' +
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
+ }
@@ -4,7 +4,7 @@ import * as sessionManager from './sessionManager.js';
4
4
  import * as serveManager from './serveManager.js';
5
5
  import * as worktreeManager from './worktreeManager.js';
6
6
  import { SSEClient } from './sseClient.js';
7
- import { formatOutput } from '../utils/messageFormatter.js';
7
+ import { formatOutput, buildContextHeader } from '../utils/messageFormatter.js';
8
8
  import { processNextInQueue } from './queueManager.js';
9
9
  export async function runPrompt(channel, threadId, prompt, parentChannelId) {
10
10
  const projectPath = dataStore.getChannelProjectPath(parentChannelId);
@@ -52,7 +52,9 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
52
52
  }
53
53
  const effectivePath = worktreeMapping?.worktreePath ?? projectPath;
54
54
  const preferredModel = dataStore.getChannelModel(parentChannelId);
55
- const modelDisplay = preferredModel ? `\`${preferredModel}\`` : 'default';
55
+ const modelDisplay = preferredModel ? `${preferredModel}` : 'default';
56
+ const branchName = worktreeMapping?.branchName ?? await worktreeManager.getCurrentBranch(effectivePath) ?? 'main';
57
+ const contextHeader = buildContextHeader(branchName, modelDisplay);
56
58
  const buttons = new ActionRowBuilder()
57
59
  .addComponents(new ButtonBuilder()
58
60
  .setCustomId(`interrupt_${threadId}`)
@@ -61,7 +63,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
61
63
  let streamMessage;
62
64
  try {
63
65
  streamMessage = await channel.send({
64
- content: `πŸ“Œ **Prompt**: ${prompt}\n\nπŸš€ Starting OpenCode server... (Model: ${modelDisplay})`,
66
+ content: `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\nπŸš€ Starting OpenCode server...`,
65
67
  components: [buttons]
66
68
  });
67
69
  }
@@ -84,7 +86,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
84
86
  };
85
87
  try {
86
88
  port = await serveManager.spawnServe(effectivePath, preferredModel);
87
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\n⏳ Waiting for OpenCode server... (Model: ${modelDisplay})`, [buttons]);
89
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n⏳ Waiting for OpenCode server...`, [buttons]);
88
90
  await serveManager.waitForReady(port, 30000, effectivePath, preferredModel);
89
91
  const settings = dataStore.getQueueSettings(threadId);
90
92
  // If fresh context is enabled, we always clear the session before starting
@@ -127,7 +129,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
127
129
  .setLabel('⏸️ Interrupt')
128
130
  .setStyle(ButtonStyle.Secondary)
129
131
  .setDisabled(true));
130
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
132
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n\`\`\`\n${formatted}\n\`\`\``, [disabledButtons]);
131
133
  await channel.send({ content: 'βœ… Done' });
132
134
  sseClient.disconnect();
133
135
  sessionManager.clearSseClient(threadId);
@@ -146,7 +148,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
146
148
  }
147
149
  (async () => {
148
150
  try {
149
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\n❌ Connection error: ${error.message}`, []);
151
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n❌ Connection error: ${error.message}`, []);
150
152
  sseClient.disconnect();
151
153
  sessionManager.clearSseClient(threadId);
152
154
  const settings = dataStore.getQueueSettings(threadId);
@@ -170,13 +172,13 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
170
172
  const newContent = formatted || 'Processing...';
171
173
  if (newContent !== lastContent || tick % 2 === 0) {
172
174
  lastContent = newContent;
173
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
175
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n${spinnerChar} **Running...**\n\`\`\`\n${newContent}\n\`\`\``, [buttons]);
174
176
  }
175
177
  }
176
178
  catch {
177
179
  }
178
180
  }, 1000);
179
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\nπŸ“ Sending prompt...`, [buttons]);
181
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\nπŸ“ Sending prompt...`, [buttons]);
180
182
  await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel);
181
183
  }
182
184
  catch (error) {
@@ -184,7 +186,7 @@ export async function runPrompt(channel, threadId, prompt, parentChannelId) {
184
186
  clearInterval(updateInterval);
185
187
  }
186
188
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
187
- await updateStreamMessage(`πŸ“Œ **Prompt**: ${prompt}\n\n❌ OpenCode execution failed: ${errorMessage}`, []);
189
+ await updateStreamMessage(`${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n❌ OpenCode execution failed: ${errorMessage}`, []);
188
190
  const client = sessionManager.getSseClient(threadId);
189
191
  if (client) {
190
192
  client.disconnect();
@@ -67,6 +67,15 @@ export async function removeWorktree(worktreePath, deleteBranch) {
67
67
  throw new Error(`Failed to remove worktree: ${error.message}`);
68
68
  }
69
69
  }
70
+ export async function getCurrentBranch(cwd) {
71
+ try {
72
+ const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd });
73
+ return stdout.trim() || null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
70
79
  export function worktreeExists(worktreePath) {
71
80
  return existsSync(worktreePath);
72
81
  }
@@ -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,
@@ -54,6 +54,9 @@ export function parseOpenCodeOutput(buffer) {
54
54
  }
55
55
  return result;
56
56
  }
57
+ export function buildContextHeader(branchName, modelName) {
58
+ return `🌿 \`${branchName}\` Β· πŸ€– \`${modelName}\``;
59
+ }
57
60
  export function formatOutput(buffer, maxLength = 1900) {
58
61
  const parsed = parseOpenCodeOutput(buffer);
59
62
  if (!parsed.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-opencode",
3
- "version": "1.0.10",
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": {
@@ -42,6 +42,7 @@
42
42
  "eventsource": "^4.1.0",
43
43
  "node-pty": "^1.1.0",
44
44
  "open": "^10.1.0",
45
+ "opencode-antigravity-auth": "^1.4.6",
45
46
  "picocolors": "^1.1.1",
46
47
  "update-notifier": "^7.3.1"
47
48
  },