starmux 0.13.1 → 0.15.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +1 -1
  3. package/README.md +20 -28
  4. package/dist/src/access-control.js +1 -2
  5. package/dist/src/access-dashboard.js +2 -3
  6. package/dist/src/account-access-policy.js +54 -0
  7. package/dist/src/authorized-workspace-roots.js +0 -1
  8. package/dist/src/batch-read-policy.js +30 -0
  9. package/dist/src/cli/instances.js +27 -187
  10. package/dist/src/cli/oauth.js +0 -1
  11. package/dist/src/cli/starmux-status.js +25 -18
  12. package/dist/src/cli/starmux-supervisor.js +0 -1
  13. package/dist/src/cli/starmux.js +47 -37
  14. package/dist/src/config-schemas.js +1 -10
  15. package/dist/src/configured-mcp-upstreams.js +0 -1
  16. package/dist/src/contracts.js +0 -1
  17. package/dist/src/current-permission-probe.js +0 -1
  18. package/dist/src/dc-workspace-http.js +57 -33
  19. package/dist/src/deny-dashboard.js +2 -3
  20. package/dist/src/desktop-commander-facade.js +4 -4
  21. package/dist/src/explicit-deny-rules.js +2 -3
  22. package/dist/src/global-core-control.js +108 -56
  23. package/dist/src/home-write-sandbox.js +0 -1
  24. package/dist/src/http-host-policy.js +20 -0
  25. package/dist/src/instance-control-protocol.js +27 -0
  26. package/dist/src/instance-runtime.js +215 -76
  27. package/dist/src/instance-store.js +28 -22
  28. package/dist/src/instance-worker.js +2 -3
  29. package/dist/src/invocation.js +1 -2
  30. package/dist/src/local-desktop-read-probe.js +5 -88
  31. package/dist/src/managed-workspace-policy.js +38 -4
  32. package/dist/src/mcp-results.js +0 -1
  33. package/dist/src/mcp-upstream-router.js +0 -1
  34. package/dist/src/oauth-keycloak.js +0 -1
  35. package/dist/src/oauth-resource.js +0 -1
  36. package/dist/src/ops-dashboard.js +2 -179
  37. package/dist/src/permissions-dashboard.js +3 -4
  38. package/dist/src/platform.js +6 -4
  39. package/dist/src/privacy-diagnostics.js +0 -1
  40. package/dist/src/private-mcp-activity.js +0 -1
  41. package/dist/src/private-store-lock.js +0 -1
  42. package/dist/src/project-dev-presets.js +0 -1
  43. package/dist/src/project-git-workflow.js +0 -1
  44. package/dist/src/project-workspace-policy.js +10 -6
  45. package/dist/src/runtime-identity.js +0 -14
  46. package/dist/src/runtime-paths.js +0 -3
  47. package/dist/src/tool-dispatch.js +45 -46
  48. package/dist/src/validation.js +0 -1
  49. package/dist/src/workspace-grants.js +1 -2
  50. package/package.json +7 -15
  51. package/scripts/prepare.mjs +1 -1
  52. package/CONTRIBUTING.md +0 -18
  53. package/SECURITY.md +0 -7
  54. package/app/README.md +0 -29
  55. package/dist/src/access-control.js.map +0 -1
  56. package/dist/src/access-dashboard.js.map +0 -1
  57. package/dist/src/authorized-workspace-roots.js.map +0 -1
  58. package/dist/src/cli/access.js +0 -53
  59. package/dist/src/cli/access.js.map +0 -1
  60. package/dist/src/cli/community-remote-revoke.js +0 -32
  61. package/dist/src/cli/community-remote-revoke.js.map +0 -1
  62. package/dist/src/cli/instances.js.map +0 -1
  63. package/dist/src/cli/live-home-settings.js +0 -35
  64. package/dist/src/cli/live-home-settings.js.map +0 -1
  65. package/dist/src/cli/mac-mcp-gateway.js +0 -257
  66. package/dist/src/cli/mac-mcp-gateway.js.map +0 -1
  67. package/dist/src/cli/oauth.js.map +0 -1
  68. package/dist/src/cli/permissions-owner.js +0 -27
  69. package/dist/src/cli/permissions-owner.js.map +0 -1
  70. package/dist/src/cli/service-setup.js +0 -26
  71. package/dist/src/cli/service-setup.js.map +0 -1
  72. package/dist/src/cli/setup-wizard.js +0 -149
  73. package/dist/src/cli/setup-wizard.js.map +0 -1
  74. package/dist/src/cli/starmux-status.js.map +0 -1
  75. package/dist/src/cli/starmux-supervisor.js.map +0 -1
  76. package/dist/src/cli/starmux.js.map +0 -1
  77. package/dist/src/cli/verify-setup.js +0 -53
  78. package/dist/src/cli/verify-setup.js.map +0 -1
  79. package/dist/src/community-remote-revocations.js +0 -135
  80. package/dist/src/community-remote-revocations.js.map +0 -1
  81. package/dist/src/config-schemas.js.map +0 -1
  82. package/dist/src/configured-mcp-upstreams.js.map +0 -1
  83. package/dist/src/contracts.js.map +0 -1
  84. package/dist/src/current-permission-probe.js.map +0 -1
  85. package/dist/src/dc-workspace-http.js.map +0 -1
  86. package/dist/src/deny-dashboard.js.map +0 -1
  87. package/dist/src/desktop-commander-facade.js.map +0 -1
  88. package/dist/src/explicit-deny-rules.js.map +0 -1
  89. package/dist/src/gateway-status-format.js +0 -66
  90. package/dist/src/gateway-status-format.js.map +0 -1
  91. package/dist/src/global-core-control.js.map +0 -1
  92. package/dist/src/home-write-sandbox.js.map +0 -1
  93. package/dist/src/instance-migration.js +0 -138
  94. package/dist/src/instance-migration.js.map +0 -1
  95. package/dist/src/instance-runtime.js.map +0 -1
  96. package/dist/src/instance-store.js.map +0 -1
  97. package/dist/src/instance-worker.js.map +0 -1
  98. package/dist/src/invocation.js.map +0 -1
  99. package/dist/src/live-profile-home-settings.js +0 -80
  100. package/dist/src/live-profile-home-settings.js.map +0 -1
  101. package/dist/src/local-desktop-read-probe.js.map +0 -1
  102. package/dist/src/managed-workspace-policy.js.map +0 -1
  103. package/dist/src/mcp-results.js.map +0 -1
  104. package/dist/src/mcp-upstream-router.js.map +0 -1
  105. package/dist/src/oauth-keycloak.js.map +0 -1
  106. package/dist/src/oauth-resource.js.map +0 -1
  107. package/dist/src/operator-cli.js +0 -559
  108. package/dist/src/operator-cli.js.map +0 -1
  109. package/dist/src/ops-dashboard.js.map +0 -1
  110. package/dist/src/permissions-dashboard.js.map +0 -1
  111. package/dist/src/platform.js.map +0 -1
  112. package/dist/src/privacy-diagnostics.js.map +0 -1
  113. package/dist/src/private-mcp-activity.js.map +0 -1
  114. package/dist/src/private-store-lock.js.map +0 -1
  115. package/dist/src/project-dev-presets.js.map +0 -1
  116. package/dist/src/project-git-workflow.js.map +0 -1
  117. package/dist/src/project-workspace-policy.js.map +0 -1
  118. package/dist/src/read-scope.js +0 -44
  119. package/dist/src/read-scope.js.map +0 -1
  120. package/dist/src/release1-onboarding.js +0 -120
  121. package/dist/src/release1-onboarding.js.map +0 -1
  122. package/dist/src/release1-service.js +0 -131
  123. package/dist/src/release1-service.js.map +0 -1
  124. package/dist/src/remote-auth.js +0 -47
  125. package/dist/src/remote-auth.js.map +0 -1
  126. package/dist/src/runtime-identity.js.map +0 -1
  127. package/dist/src/runtime-paths.js.map +0 -1
  128. package/dist/src/starmux-migration.js +0 -172
  129. package/dist/src/starmux-migration.js.map +0 -1
  130. package/dist/src/tool-dispatch.js.map +0 -1
  131. package/dist/src/validation.js.map +0 -1
  132. package/dist/src/workspace-grants.js.map +0 -1
  133. package/docs/ACCESS_CONTROL.md +0 -51
  134. package/docs/CLIENTS.md +0 -22
  135. package/docs/CONNECT_CHATGPT.md +0 -148
  136. package/docs/INSTALL.md +0 -62
  137. package/docs/INSTANCES.md +0 -83
  138. package/docs/OAUTH.md +0 -116
  139. package/docs/PLATFORMS.md +0 -29
  140. package/docs/README.md +0 -16
  141. package/docs/REMOTE_SETUP.md +0 -280
  142. package/docs/SECURITY.md +0 -15
  143. package/src/dc-workspace-http.mjs +0 -3
  144. package/test/fixtures/p1-fixture.txt +0 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## 0.15.0
4
+
5
+ ### Instance lifecycle
6
+
7
+ - Version and validate the local instance control protocol independently of package releases.
8
+ - Report `unknown` when control records or channels cannot establish the instance's state; do not mistake a missing control channel for a stopped service.
9
+ - Confirm cleanup before `down` reports success. Repeated stops report `Already stopped`; failed or timed-out cleanup returns a nonzero exit code.
10
+ - Use the same stop confirmation for instance archival and protect successor runs during concurrent operations.
11
+ - Separate confirmed active configuration, saved settings and historical snapshots in CLI and JSON output.
12
+ - Prevent `up` from changing permissions in an unknown state, prevent `doctor` from passing unresolved runtime errors, and show unknown states in the macOS App.
13
+ - Add isolated lifecycle and real CLI regression tests, validated on macOS and NVIDIA NX Linux ARM64.
14
+
15
+ ### Upgrade
16
+
17
+ Stop each running instance with the currently installed CLI before upgrading, then start it again with the new CLI. This release introduces a versioned instance control contract and does not migrate old runtime directories or support the previous unversioned contract. Replacing installed files does not update an already-running supervisor.
18
+
19
+ ```sh
20
+ starmux ls
21
+ starmux down # Default instance
22
+ starmux down -i NAME # Repeat for other running instances
23
+ npm install -g starmux@0.15.0
24
+ starmux up
25
+ ```
26
+
27
+ Proceed with installation only after stopping succeeds. If the current CLI cannot contact its supervisor, resolve that runtime problem first; a port conflict is not proof of successful shutdown.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Mac MCP Gateway contributors
3
+ Copyright (c) 2026 Starmux contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -24,7 +24,7 @@ Cloud AI assistants (like **ChatGPT Web**, **Claude**, **Codex**, or **WorkBuddy
24
24
 
25
25
  - **Batteries-Included Workspace Tools:** Powered by a built-in [Desktop Commander](https://github.com/wonderwhy-er/DesktopCommanderMCP) engine (installed automatically via npm). Out of the box, your AI can read/write files, search code, run Shell commands, and manage Git.
26
26
  - **Code on Any Host (macOS, Linux, Windows & Edge Devices):** Many AI clients (like **ChatGPT Desktop** or **WorkBuddy**) have no native Linux or ARM64 client. Because Starmux runs anywhere **Node.js 22.12+** runs—including **macOS**, **Linux (`x64` / `arm64`)**, **NVIDIA Jetson**, headless servers, and **Windows**—you can use your favorite AI client on your main computer or browser to develop directly on any target machine.
27
- - **Safe by Default:** Starts in strict **Read-Only** mode (`starmux up`). Explicitly opt into **Coding Mode** (`starmux up --code`) only when you want AI to edit files or run terminal commands.
27
+ - **Two Clear Permission Modes:** Starts in global **Read-Only** mode (`starmux up`): AI can read files your OS account and the upstream server permit, while writes and arbitrary execution are blocked. Explicitly opt into **Coding Mode** (`starmux up --code`) to use native tools with your OS account permissions.
28
28
  - **Your Network, Your Keys:** No vendor-hosted relay required. Protect your gateway with Bearer **API Keys**, browser-based **OAuth**, or your existing **Tunnel / Reverse Proxy** access controls. Revoke access instantly at any time.
29
29
 
30
30
  ---
@@ -39,12 +39,14 @@ Requires **Node.js 22.12+**.
39
39
  npm install -g starmux
40
40
  ```
41
41
 
42
+ **Upgrading an existing installation?** Stop each running instance with the current CLI (`starmux down`, or `starmux down -i NAME`) before installing the new version, then run `starmux up` again. See the [upgrade instructions](https://github.com/cookyecat/starmux/blob/main/docs/INSTALL.md#upgrade) and [release notes](https://github.com/cookyecat/starmux/blob/main/CHANGELOG.md).
43
+
42
44
  ### 2. Start in your project directory
43
45
 
44
46
  ```bash
45
47
  cd /path/to/your/project
46
48
 
47
- # Option A: Safe read-only mode (default — inspect files & code)
49
+ # Option A: Global read-only mode (default — inspect files & code)
48
50
  starmux up
49
51
 
50
52
  # Option B: Full coding mode (edit files, run Shell & Git)
@@ -53,28 +55,18 @@ starmux up --code
53
55
 
54
56
  Starmux initializes itself and runs in the background on port `18893` (coding mode asks for confirmation the first time):
55
57
 
56
- ```text
57
- ● Starmux is running
58
- Listen http://127.0.0.1:18893/mcp (this computer only)
59
- Workspace /path/to/your/project
60
- Mode Read-only (run 'starmux up --code' to enable edits & shell)
61
- Auth None (no token required)
62
- Runtime Background · continues after this terminal closes
63
-
64
- Connect an AI client (MCP settings)
65
- Transport Streamable HTTP
66
- URL (Local) http://127.0.0.1:18893/mcp (AI client on this computer)
67
- URL (Remote) https://<your-domain-or-tunnel>/mcp (via protected HTTPS proxy or tunnel -> 127.0.0.1:18893)
68
- Authentication None (optional Bearer API key: starmux auth key "My AI Client")
69
-
70
- Commands
71
- Verify starmux verify
72
- Auth & keys starmux auth
73
- Dashboard starmux dashboard (view & copy settings in browser)
74
- Logs starmux logs -f
75
- Stop starmux down
58
+ Switch back from Coding mode with
59
+
60
+ ```
61
+ starmux up --read
76
62
  ```
77
63
 
64
+ Add `-i NAME` for a named instance. Both `up` and `status` show the mode-switch command.
65
+
66
+ `Project` provides context for relative file paths in Read-only mode; it does not restrict access to that directory. Read-only applies across the computer wherever the OS account and upstream server permit reading. Coding forwards native operations with the account's permissions. Neither mode uses Starmux directory grants or path exclusions.
67
+
68
+ In Read-only mode, `read_multiple_files` supports up to 20 files and 4 MiB of combined file sizes per batch. Missing files retain Desktop Commander's per-file errors alongside successful contents. Oversized batches return `BATCH_READ_COUNT_LIMIT` or `BATCH_READ_SIZE_LIMIT` with guidance to split the request.
69
+
78
70
  ### 3. Add Starmux to your AI client
79
71
 
80
72
  Need a Bearer API key for remote access? Generate one first (optional for local use):
@@ -176,7 +168,7 @@ See [Client Compatibility](docs/CLIENTS.md) and [Remote Setup](docs/REMOTE_SETUP
176
168
 
177
169
  ---
178
170
 
179
- ## Everyday CLI & Multi-Workspace
171
+ ## Everyday CLI & Multiple Instances
180
172
 
181
173
  Manage your gateway with simple, human-friendly commands:
182
174
 
@@ -190,11 +182,11 @@ starmux logs -f # Stream live gateway logs
190
182
  starmux down # Stop the gateway (alias: stop)
191
183
  ```
192
184
 
193
- After initialization, `starmux up` reuses your saved workspace. Use `starmux up .` to switch it to your current directory, or pass `--foreground` to keep the process in your terminal.
185
+ After initialization, `starmux up` reuses your instance's saved project context. Use `starmux up .` to change it to your current directory, or pass `--foreground` to keep the process in your terminal. Only a new instance defaults to the current directory when no path is supplied.
194
186
 
195
- ### Advanced: Running multiple workspaces (`-i`)
187
+ ### Advanced: Running multiple instances (`-i`)
196
188
 
197
- *(Optional)* By default, Starmux manages a single `default` workspace so you never have to pass extra flags. When you need to expose multiple projects simultaneously or run different permission modes on the same machine, pass `-i <name>` to create isolated instances with their own ports, credentials, policies, and logs:
189
+ *(Optional)* By default, Starmux manages a single `default` instance so you never have to pass extra flags. To use different project contexts or permission modes on the same machine, pass `-i <name>` to create instances with their own ports, credentials, configuration, and logs. Instances share the OS account; they are not filesystem sandboxes:
198
190
 
199
191
  ```bash
200
192
  starmux up ~/work/repo-a -i work --code
@@ -238,8 +230,8 @@ Explore the `docs/` guides by topic:
238
230
  - [Access Control & API Keys](docs/ACCESS_CONTROL.md) — Creating/revoking Bearer API keys, expiration policies, and using the local Web Access Console.
239
231
  - [ChatGPT Web & OAuth 2.0](docs/OAUTH.md) & [Connect ChatGPT](docs/CONNECT_CHATGPT.md) — Configuring Keycloak OAuth 2.0 + PKCE and OpenAI Secure MCP Tunnel.
240
232
  - **⚙️ Advanced Configuration & Security**
241
- - [Multi-Instance & JSONC Config](docs/INSTANCES.md) — Managing multiple isolated workspaces, hot-reloading `config.jsonc`, mounting extra `stdio` MCP upstreams, and secrets.
242
- - [Security & Workspace Permissions](docs/SECURITY.md) — Understanding Read-Only vs. Coding Mode, directory grants, tool deny rules, and macOS privacy boundaries.
233
+ - [Multi-Instance & JSONC Config](docs/INSTANCES.md) — Managing instances, hot-reloading `config.jsonc`, mounting extra `stdio` MCP upstreams, and secrets.
234
+ - [Security & Permissions](docs/SECURITY.md) — Understanding global Read-Only vs. Coding Mode, upstream permissions, and macOS privacy boundaries.
243
235
  - [Client Compatibility](docs/CLIENTS.md) & [Platform Details](docs/PLATFORMS.md) — Tested AI client matrix and macOS / Linux / Windows runtime specifics.
244
236
  - **🛠️ Reference & Project**
245
237
  - [Documentation Index](docs/README.md) · [Installation](docs/INSTALL.md) · [Contributing](CONTRIBUTING.md)
@@ -12,7 +12,7 @@ const schema = z.object({ version: z.literal(1), mode: z.enum(['ingress', 'api-k
12
12
  oauth: oauthConfigSchema.optional(), oauthConnections: z.array(oauthConnectionSchema).max(512).default([]) }).strict();
13
13
  const digest = (value) => createHash('sha256').update(value).digest('hex');
14
14
  const equal = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
15
- export const defaultAccessFile = () => process.env.MAC_MCP_ACCESS_FILE || path.join(homedir(), '.config', 'starmux', 'access-v1.json');
15
+ export const defaultAccessFile = () => process.env.STARMUX_ACCESS_FILE || path.join(homedir(), '.config', 'starmux', 'access-v1.json');
16
16
  const active = (c) => !c.revokedAt && (!c.expiresAt || Date.parse(c.expiresAt) > Date.now());
17
17
  export async function createAccessControl(file = defaultAccessFile(), options = {}) {
18
18
  if (!path.isAbsolute(file))
@@ -222,4 +222,3 @@ export async function createAccessControl(file = defaultAccessFile(), options =
222
222
  }
223
223
  return { file, snapshot, create, setMode, revoke, authenticate, ownerToken, authorized, configureOAuth, revokeOAuth, oauthSettings };
224
224
  }
225
- //# sourceMappingURL=access-control.js.map
@@ -8,8 +8,8 @@ const e = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<':
8
8
  export function accessPage(state, created) {
9
9
  const form = (op, fields, label) => `<form method="post" action="/access"><input type="hidden" name="op" value="${op}">${fields}<label>Owner management token <input type="password" name="token" required autocomplete="off"></label><button>${label}</button></form>`;
10
10
  return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Starmux · Access Control</title><style>body{font:16px system-ui;max-width:960px;margin:40px auto;padding:0 24px;background:#f5f7fa;color:#243249}section{background:white;border:1px solid #dae0e8;border-radius:12px;padding:24px;margin:20px 0}input,select,button{font:inherit;padding:8px;margin:8px}label{display:block}code{overflow-wrap:anywhere}td,th{text-align:left;padding:10px;border-bottom:1px solid #ddd}table{width:100%}</style><h1>Starmux · Access Control</h1>
11
- <p><a href="/dashboard">Dashboard</a> · <a href="/permissions">Workspace permissions</a></p>
12
- <section><h2>${state.mode === 'ingress' ? 'None (local or ingress-managed)' : state.mode === 'oauth' ? 'OAuth' : 'Bearer API Key'}</h2><p>Permission: Owner default. Credentials do not change workspace, tool or operating-system permissions.</p><p>${state.mode === 'ingress' ? 'Starmux does not verify a token. Restrict remote access through your HTTPS proxy or tunnel.' : 'Every MCP request requires an active credential. Missing, invalid, expired or revoked credentials are rejected.'}</p>
11
+ <p><a href="/dashboard">Dashboard</a></p>
12
+ <section><h2>${state.mode === 'ingress' ? 'None (local or ingress-managed)' : state.mode === 'oauth' ? 'OAuth' : 'Bearer API Key'}</h2><p>Credentials do not change the instance’s mode or operating-system permissions.</p><p>${state.mode === 'ingress' ? 'Starmux does not verify a token. Restrict remote access through your HTTPS proxy or tunnel.' : 'Every MCP request requires an active credential. Missing, invalid, expired or revoked credentials are rejected.'}</p>
13
13
  <table><tr><th>MCP Client Field</th><th>Value to Copy</th></tr>
14
14
  <tr><td>Transport</td><td><code>Streamable HTTP</code></td></tr>
15
15
  <tr><td>URL (Local)</td><td><code>http://127.0.0.1:&lt;PORT&gt;/mcp</code></td></tr>
@@ -74,4 +74,3 @@ export async function accessPost(req, access) {
74
74
  return { status: 400 };
75
75
  }
76
76
  }
77
- //# sourceMappingURL=access-dashboard.js.map
@@ -0,0 +1,54 @@
1
+ import { realpath, stat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { GatewayError, errorCode } from './validation.js';
5
+ import { prepareBatchRead } from './batch-read-policy.js';
6
+ // Reviewed Desktop Commander operations. Tool annotations are hints, not an
7
+ // authorization boundary; unknown tools cannot opt themselves into read-only.
8
+ const readOnlyTools = new Set(['health_check', 'get_config', 'read_file', 'read_multiple_files',
9
+ 'list_directory', 'get_file_info', 'start_search', 'get_more_search_results', 'stop_search',
10
+ 'list_searches', 'read_process_output', 'list_sessions', 'list_processes', 'get_usage_stats', 'get_recent_tool_calls']);
11
+ export const isReadOnlyTool = (name) => readOnlyTools.has(name);
12
+ export async function createAccountAccessPolicy(root, mode = 'read-only') {
13
+ if (!path.isAbsolute(root))
14
+ throw new GatewayError('ROOT_REQUIRED');
15
+ const base = await realpath(root);
16
+ if (!(await stat(base)).isDirectory())
17
+ throw new GatewayError('NOT_DIRECTORY');
18
+ function absolute(raw) {
19
+ if (typeof raw !== 'string' || !raw || raw.includes('\0'))
20
+ throw new GatewayError('INVALID_PATH');
21
+ const expanded = raw === '~' ? homedir() : raw.startsWith('~/') ? path.join(homedir(), raw.slice(2)) : raw;
22
+ return path.resolve(base, expanded);
23
+ }
24
+ const policy = {
25
+ root: base, mode, developerCommands: 'off',
26
+ async validateGitPath() { throw new GatewayError('READ_ONLY_MODE'); },
27
+ async prepare(name, args = {}) {
28
+ if (!isReadOnlyTool(name))
29
+ throw new GatewayError('READ_ONLY_MODE');
30
+ if (name === 'read_multiple_files')
31
+ return prepareBatchRead(args, policy.prepare);
32
+ if (['read_file', 'list_directory', 'get_file_info', 'start_search'].includes(name)) {
33
+ let target = absolute(args.path);
34
+ if (name === 'read_file') {
35
+ // Canonicalize existing links for accurate batch byte accounting, while
36
+ // letting upstream retain its native missing-file errors.
37
+ try {
38
+ target = await realpath(target);
39
+ const info = await stat(target);
40
+ if (info.isFile() && info.size > 1024 * 1024)
41
+ throw new GatewayError('FILE_TOO_LARGE');
42
+ }
43
+ catch (error) {
44
+ if (errorCode(error) !== 'ENOENT')
45
+ throw error;
46
+ }
47
+ }
48
+ return { name, arguments: { ...args, path: target } };
49
+ }
50
+ return { name, arguments: args };
51
+ }
52
+ };
53
+ return Object.freeze(policy);
54
+ }
@@ -74,4 +74,3 @@ export function createAuthorizedWorkspacePolicy({ basePolicy, linkedGitWorktrees
74
74
  }
75
75
  });
76
76
  }
77
- //# sourceMappingURL=authorized-workspace-roots.js.map
@@ -0,0 +1,30 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { GatewayError, errorCode } from './validation.js';
3
+ // Validate every file before forwarding any part of a batch upstream.
4
+ export async function prepareBatchRead(args, prepare) {
5
+ if (!args || Object.keys(args).some(key => key !== 'paths') || !Array.isArray(args.paths) ||
6
+ args.paths.length < 1 || args.paths.some(p => typeof p !== 'string'))
7
+ throw new GatewayError('BATCH_READ_ARGUMENTS_DENIED');
8
+ if (args.paths.length > 20)
9
+ throw new GatewayError('BATCH_READ_COUNT_LIMIT');
10
+ const paths = [];
11
+ let bytes = 0;
12
+ for (const raw of args.paths) {
13
+ const invocation = await prepare('read_file', { path: raw });
14
+ const target = invocation.arguments?.path;
15
+ if (typeof target !== 'string')
16
+ throw new GatewayError('BATCH_READ_PATH_INVALID');
17
+ const st = await lstat(target).catch(error => {
18
+ // Authorization already succeeded. Keep missing paths in the upstream
19
+ // batch so Desktop Commander can report ENOENT beside successful reads.
20
+ if (errorCode(error) === 'ENOENT')
21
+ return null;
22
+ throw error;
23
+ });
24
+ bytes += st?.size ?? 0;
25
+ if (bytes > 4 * 1024 * 1024)
26
+ throw new GatewayError('BATCH_READ_SIZE_LIMIT');
27
+ paths.push(target);
28
+ }
29
+ return { name: 'read_multiple_files', arguments: { paths } };
30
+ }
@@ -1,4 +1,3 @@
1
- import { createInterface } from 'node:readline/promises';
2
1
  import { spawn } from 'node:child_process';
3
2
  import { readFile, writeFile, mkdtemp, rm, realpath } from 'node:fs/promises';
4
3
  import { tmpdir, homedir } from 'node:os';
@@ -7,9 +6,9 @@ import { createAccessControl } from '../access-control.js';
7
6
  import { createWorkspaceGrants } from '../workspace-grants.js';
8
7
  import { createDenyRules } from '../explicit-deny-rules.js';
9
8
  import { createPrivateDirectory } from '../platform.js';
10
- import { instanceStatus, instanceRequest, runInstance } from '../instance-runtime.js';
11
- import { atomicPrivate, changeRegistry, configJSONSchema, sourceRevision, configTemplate, createInstance, exists, fingerprint, instanceConfigSchema, instanceEnv, instancePaths, instancesRoot, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort, validateConfig, withLock } from '../instance-store.js';
12
- const booleans = new Set(['--json', '--all', '--link', '--archived', '--approve-trusted-developer', '--approve-unrestricted', '--confirm', '--effective', '--allow-loopback-http']);
9
+ import { instanceStatus, instanceRequest, stopInstance } from '../instance-runtime.js';
10
+ import { atomicPrivate, changeRegistry, configJSONSchema, sourceRevision, configTemplate, validateConfig, withLock, createInstance, exists, fingerprint, instancePaths, loadInstanceConfig, parseConfig, patchInstanceConfig, readRegistry, readTrusted, resolveInstance, saveInstanceConfig, suggestPort } from '../instance-store.js';
11
+ const booleans = new Set(['--json', '--all', '--link', '--archived', '--approve-trusted-developer', '--approve-unrestricted', '--effective', '--allow-loopback-http']);
13
12
  function options(args) {
14
13
  const flags = new Map();
15
14
  const positional = [];
@@ -44,75 +43,10 @@ function requireApprovals(config, flags) {
44
43
  if (config.accessRole === 'trusted' && process.platform !== 'darwin' && !flags.has('--approve-unrestricted'))
45
44
  throw Error('UNRESTRICTED_APPROVAL_REQUIRED: add --approve-unrestricted');
46
45
  }
47
- async function wizard(flags, current) {
48
- let project = flags.get('--project') ?? current?.projectRoot;
49
- let role = flags.get('--access-role') ?? current?.accessRole ?? 'restricted';
50
- let port = Number(flags.get('--port') ?? current?.port ?? await suggestPort());
51
- if (!project) {
52
- if (!process.stdin.isTTY)
53
- throw Error('PROJECT_REQUIRED: use --project /absolute/directory');
54
- const rl = createInterface({ input: process.stdin, output: process.stderr });
55
- try {
56
- project = await rl.question('Work directory: ');
57
- role = (await rl.question('Capability (restricted / trusted) [restricted]: ')) || 'restricted';
58
- if (role === 'trusted' && (await rl.question('Enable native tools and Shell as your OS account? Type yes: ')) === 'yes') {
59
- flags.set('--approve-trusted-developer', 'true');
60
- if (process.platform !== 'darwin')
61
- flags.set('--approve-unrestricted', 'true');
62
- }
63
- port = Number((await rl.question(`Local port [${port}]: `)) || port);
64
- }
65
- finally {
66
- rl.close();
67
- }
68
- }
69
- const config = instanceConfigSchema.parse({ ...current, schemaVersion: 1, projectRoot: await realpath(path.resolve(project)), port,
70
- accessRole: role, mode: current?.accessRole === role ? current.mode : role === 'trusted' ? 'read-write' : 'read-only', writeScope: current?.accessRole === role ? current.writeScope : role === 'trusted' && process.platform === 'darwin' ? 'home-only' : 'unrestricted', connection: flags.get('--connection') ?? current?.connection ?? 'local' });
71
- requireApprovals(config, flags);
72
- return validateConfig(config);
73
- }
74
- export async function maybeHandleInstances(argv) {
75
- let command = argv[0] ?? 'help';
76
- if (command === 'start-setup' && !process.env.MAC_MCP_INTERNAL_SETUP_OWNER_PID) {
77
- command = 'run';
78
- argv = ['run', '--profile', 'setup', ...argv.slice(1)];
79
- }
80
- if (command === 'doctor-setup') {
81
- command = 'doctor';
82
- argv = ['doctor', '--profile', 'setup', ...argv.slice(1)];
83
- }
84
- const supported = ['instance', 'config', 'run', 'status', 'stop', 'restart', 'logs', 'doctor', 'verify', 'access', 'permissions', 'privacy', 'setup'];
85
- if (!supported.includes(command))
86
- return false;
87
- try {
88
- const explicit = argv.includes('--instance');
89
- const registry = await readRegistry();
90
- const managed = registry.instances.length > 0;
91
- const firstWizard = command === 'setup' && argv.length === 1 && process.stdin.isTTY;
92
- if (!['instance', 'config'].includes(command) && !explicit && !managed && !firstWizard)
93
- return false;
94
- // A retained legacy profile remains available until the owner migrates it.
95
- const profileIndex = argv.indexOf('--profile');
96
- if (profileIndex >= 0 && !registry.instances.some(i => i.legacy === argv[profileIndex + 1]) && argv[profileIndex + 1] !== 'auto') {
97
- if (registry.instances.some(i => i.legacy))
98
- throw Error('LEGACY_PROFILE_NOT_MIGRATED_USE_INSTANCE: select --instance explicitly');
99
- return false;
100
- }
101
- await handle(command, argv.slice(1));
102
- }
103
- catch (error) {
104
- if (argv.includes('--json'))
105
- output({ schemaVersion: 1, error: message(error) });
106
- else
107
- process.stderr.write(message(error) + '\n');
108
- process.exitCode = 1;
109
- }
110
- return true;
111
- }
112
46
  export async function handle(command, args) {
113
47
  const { flags, positional } = options(args);
114
48
  const json = flags.has('--json');
115
- const known = new Set([...booleans, '--instance', '--name', '--project', '--port', '--access-role', '--connection', '--profile', '--app-parent-pid', '--revision', '--patch', '--file', '--output', '--lines', '--expires', '--issuer', '--resource', '--resource-aliases', '--client-id', '--subjects', '--registration', '--token-auth', '--callback', '--target', '--mode']);
49
+ const known = new Set([...booleans, '--instance', '--name', '--project', '--port', '--access-role', '--connection', '--app-parent-pid', '--revision', '--patch', '--file', '--output', '--lines', '--expires', '--issuer', '--resource', '--resource-aliases', '--client-id', '--subjects', '--registration', '--token-auth', '--callback', '--target', '--mode']);
116
50
  for (const flag of flags.keys())
117
51
  if (!known.has(flag))
118
52
  throw Error('UNKNOWN_OPTION: ' + flag);
@@ -131,16 +65,17 @@ export async function handle(command, args) {
131
65
  : 'No instances. Run: starmux up\n');
132
66
  return;
133
67
  }
134
- const rows = await Promise.all(instances.map(async (s, idx) => {
135
- const raw = filtered[idx];
136
- const cfg = s.active?.config ?? (await loadInstanceConfig(raw).catch(() => null))?.config;
68
+ const rows = instances.map(s => {
69
+ const cfg = s.phase === 'stopped' ? s.saved?.config : s.active?.config;
70
+ const saved = s.saved?.config;
137
71
  const mark = s.instanceId === registry.defaultInstanceId ? '*' : ' ';
138
72
  const state = s.archived ? 'archived' : s.configState === 'pending' ? `${s.phase} (pending)` : s.phase;
139
- const mode = cfg?.accessRole === 'trusted' ? 'coding' : (s.workspaceMode ?? 'read-only');
73
+ const describe = (value) => !value ? 'unavailable' : value.accessRole === 'trusted' ? 'coding' : value.mode;
74
+ const mode = s.phase === 'stopped' ? `${describe(saved)} (saved)` : s.phase === 'unknown' ? `unavailable / ${describe(saved)} (saved)` : s.configState === 'pending' ? `${describe(cfg)} / ${describe(saved)} (saved)` : describe(cfg);
140
75
  const port = String(s.port ?? '—');
141
- const workspace = cfg?.projectRoot ?? s.source.path;
76
+ const workspace = cfg?.projectRoot ?? (saved ? `${saved.projectRoot} (saved)` : s.source.path);
142
77
  return { mark, name: s.name, state, mode, port, workspace };
143
- }));
78
+ });
144
79
  const wName = Math.max(4, ...rows.map(r => r.name.length));
145
80
  const wState = Math.max(6, ...rows.map(r => r.state.length));
146
81
  const wMode = Math.max(4, ...rows.map(r => r.mode.length));
@@ -152,12 +87,6 @@ export async function handle(command, args) {
152
87
  }
153
88
  return;
154
89
  }
155
- if (op === 'create') {
156
- const config = await wizard(flags);
157
- const instance = await createInstance(positional[1] ?? flags.get('--name') ?? 'default', { config });
158
- output({ instance, message: 'Saved. Start with: starmux up -i ' + JSON.stringify(instance.name) });
159
- return;
160
- }
161
90
  if (op === 'import') {
162
91
  const file = positional[1];
163
92
  if (!file)
@@ -168,11 +97,6 @@ export async function handle(command, args) {
168
97
  message: flags.has('--link') ? 'Bound to original file. Changes require apply.' : 'Imported a private copy. The original file is no longer used.' });
169
98
  return;
170
99
  }
171
- if (op === 'migrate') {
172
- const { migrateLegacy } = await import('../instance-migration.js');
173
- output(await migrateLegacy(positional[1], flags.has('--confirm'), flags.get('--name')));
174
- return;
175
- }
176
100
  const instance = await resolveInstance(positional[1] ?? flags.get('--instance'), homedir(), op === 'restore');
177
101
  if (op === 'copy') {
178
102
  const config = { ...(await loadInstanceConfig(instance)).config, port: await suggestPort() };
@@ -198,9 +122,9 @@ export async function handle(command, args) {
198
122
  else if (op === 'default')
199
123
  registry.defaultInstanceId = item.id;
200
124
  else if (op === 'archive' || op === 'remove') {
201
- const stopped = await instanceRequest(instance, 'stop');
202
- if (stopped && stopped.phase !== 'stopped')
203
- throw Error('INSTANCE_STILL_RUNNING');
125
+ const stopped = await stopInstance(instance);
126
+ if (stopped.restarted)
127
+ throw Error('INSTANCE_RESTARTED: cannot archive a new running instance');
204
128
  item.archived = true;
205
129
  if (registry.defaultInstanceId === item.id)
206
130
  registry.defaultInstanceId = null;
@@ -222,47 +146,9 @@ export async function handle(command, args) {
222
146
  process.stdout.write(`Restored instance '${instance.name}'.\n`);
223
147
  return;
224
148
  }
225
- if (command === 'status' && !flags.has('--instance') && (!json || flags.has('--all')))
226
- return handle('instance', ['list', ...(json ? ['--json'] : [])]);
227
- const profile = flags.get('--profile');
228
- let selector = flags.get('--instance');
229
- if (profile && profile !== 'auto') {
230
- const legacy = (await readRegistry()).instances.find(i => i.legacy === profile);
231
- if (!legacy)
232
- throw Error('LEGACY_PROFILE_NOT_MIGRATED');
233
- selector = legacy.id;
234
- }
235
- if (command === 'setup' && !(await readRegistry()).instances.length) {
236
- await handle('instance', ['create', 'default', ...args]);
237
- return;
238
- }
149
+ const selector = flags.get('--instance');
239
150
  const instance = await resolveInstance(selector);
240
151
  const p = instancePaths(instance);
241
- if (command === 'status') {
242
- output(await instanceStatus(instance));
243
- return;
244
- }
245
- if (command === 'run') {
246
- if (flags.has('--port'))
247
- throw Error('INSTANCE_PORT_OVERRIDE_DENIED: edit configuration and apply');
248
- await runInstance(instance, { json, ...(flags.has('--app-parent-pid') ? { appParentPid: Number(flags.get('--app-parent-pid')) } : {}) });
249
- return;
250
- }
251
- if (command === 'stop') {
252
- output({ result: (await instanceRequest(instance, 'stop')) ? 'STOPPED' : 'ALREADY_STOPPED', instanceId: instance.id });
253
- return;
254
- }
255
- if (command === 'restart') {
256
- await handle('config', ['apply', '--instance', instance.id, ...(json ? ['--json'] : [])]);
257
- return;
258
- }
259
- if (command === 'setup') {
260
- const current = await loadInstanceConfig(instance);
261
- const config = await wizard(flags, current.config);
262
- await patchInstanceConfig(instance, config, current.revision);
263
- output({ result: 'SAVED', instanceId: instance.id, message: 'Apply changes to restart the running instance.' });
264
- return;
265
- }
266
152
  if (command === 'config') {
267
153
  const op = positional[0] ?? 'show';
268
154
  if (op === 'path') {
@@ -278,11 +164,12 @@ export async function handle(command, args) {
278
164
  }
279
165
  if (op === 'show') {
280
166
  if (flags.has('--effective')) {
281
- const active = (await instanceStatus(instance)).active;
167
+ const status = await instanceStatus(instance);
168
+ const active = status.active;
282
169
  if (json)
283
170
  output(active);
284
171
  else if (!active)
285
- process.stdout.write(`Instance ${instance.name} has no active running configuration.\n`);
172
+ process.stdout.write(`Instance ${instance.name} has no confirmed active configuration (state: ${status.phase}).\n`);
286
173
  else
287
174
  process.stdout.write(`Instance ${instance.name} (effective running config)\n` +
288
175
  `Workspace ${active.config.projectRoot}\n` +
@@ -320,7 +207,7 @@ export async function handle(command, args) {
320
207
  return;
321
208
  }
322
209
  if (op === 'recover') {
323
- const snapshot = (await instanceStatus(instance)).active;
210
+ const snapshot = (await instanceStatus(instance)).lastActive;
324
211
  if (!snapshot)
325
212
  throw Error('NO_SUCCESSFUL_CONFIG_SNAPSHOT');
326
213
  await validateConfig(snapshot.config);
@@ -406,7 +293,14 @@ export async function handle(command, args) {
406
293
  return;
407
294
  }
408
295
  if (op === 'apply') {
296
+ const before = await instanceStatus(instance);
297
+ if (before.phase !== 'running' && before.phase !== 'stopped')
298
+ throw Error(`INSTANCE_NOT_READY: ${before.runtimeProblem ?? before.phase}`);
409
299
  const state = await instanceRequest(instance, 'apply', flags.get('--revision') ?? loaded.revision);
300
+ if (state && state.phase !== 'running')
301
+ throw Error('INSTANCE_APPLY_UNVERIFIED');
302
+ if (!state && before.phase === 'running')
303
+ throw Error('INSTANCE_CHANGED_DURING_APPLY');
410
304
  if (json)
411
305
  output({ result: state ? 'APPLIED' : 'VALID_ON_NEXT_START', instanceId: instance.id, runtime: state });
412
306
  else
@@ -444,29 +338,6 @@ export async function handle(command, args) {
444
338
  }
445
339
  throw Error('CONFIG_ACTION_INVALID');
446
340
  }
447
- if (command === 'logs') {
448
- const file = path.join(p.logs, 'core.log');
449
- const lines = Math.min(200, Math.max(1, Number(flags.get('--lines') ?? 60)));
450
- if (!Number.isSafeInteger(Number(flags.get('--lines') ?? 60)) || Number(flags.get('--lines') ?? 60) < 1 || Number(flags.get('--lines') ?? 60) > 200)
451
- throw Error('INVALID_LINES: expected 1..200');
452
- const text = await exists(file) ? (await readTrusted(file, true, 32 * 1024 * 1024)).split('\n').slice(-lines).join('\n') : 'No logs yet.';
453
- if (json)
454
- output({ instanceId: instance.id, output: text });
455
- else
456
- process.stdout.write(text + '\n');
457
- return;
458
- }
459
- if (command === 'access') {
460
- process.env.MAC_MCP_ACCESS_FILE = p.access;
461
- const clean = args.filter((_, i) => args[i] !== '--instance' && args[i - 1] !== '--instance' && args[i] !== '--profile' && args[i - 1] !== '--profile');
462
- await (await import('./access.js')).accessCommand(clean);
463
- if (positional[0] === 'mode') {
464
- const mode = positional[1];
465
- if (mode === 'ingress' || mode === 'api-key' || mode === 'oauth')
466
- await changeRegistry(async (r) => { r.instances.find(i => i.id === instance.id).requiredAccess = mode; });
467
- }
468
- return;
469
- }
470
341
  if (command === 'permissions') {
471
342
  const grants = await createWorkspaceGrants({ file: p.grants });
472
343
  const deny = await createDenyRules({ file: p.deny });
@@ -482,15 +353,6 @@ export async function handle(command, args) {
482
353
  }
483
354
  return;
484
355
  }
485
- const loaded = await loadInstanceConfig(instance);
486
- if (command === 'doctor') {
487
- const { ensureAccessReady } = await import('../instance-store.js');
488
- await ensureAccessReady(instance);
489
- const status = await instanceStatus(instance);
490
- output({ result: 'PASS', instanceId: instance.id, source: instance.source, config: loaded.config, configState: status.configState,
491
- message: 'Configuration and access state validated. Run verify after starting to check real tools.' });
492
- return;
493
- }
494
356
  if (command === 'privacy') {
495
357
  const status = await instanceStatus(instance);
496
358
  if (!status.runtime?.port)
@@ -498,27 +360,5 @@ export async function handle(command, args) {
498
360
  output(await (await import('../privacy-diagnostics.js')).privacyReport({ port: status.runtime.port, home: homedir(), all: flags.has('--all'), target: flags.get('--target') ?? null }));
499
361
  return;
500
362
  }
501
- if (command === 'verify') {
502
- const state = await instanceRequest(instance, 'status');
503
- if (state?.phase !== 'running' || !state.port)
504
- throw Error('INSTANCE_NOT_RUNNING');
505
- const endpoint = `http://127.0.0.1:${state.port}`;
506
- const health = await fetch(endpoint + '/healthz', { signal: AbortSignal.timeout(3000) }).then(r => r.json());
507
- if (health.instanceId !== instance.id)
508
- throw Error('INSTANCE_HEALTH_IDENTITY_MISMATCH');
509
- const request = async (method, params) => {
510
- const reply = await fetch(endpoint + '/mcp', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(process.env.MAC_MCP_API_KEY ? { authorization: 'Bearer ' + process.env.MAC_MCP_API_KEY } : {}) }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(15000) });
511
- if (!reply.ok)
512
- throw Error(reply.status === 401 ? 'VERIFICATION_CREDENTIAL_REQUIRED: set MAC_MCP_API_KEY' : 'MCP_VERIFICATION_FAILED');
513
- const data = await reply.json();
514
- if (data.error || data.result?.isError)
515
- throw Error('MCP_VERIFICATION_FAILED');
516
- return data.result;
517
- };
518
- await request('tools/list', {});
519
- await request('tools/call', { name: 'list_directory', arguments: { path: '.', depth: 0 } });
520
- output({ result: 'PASS', instanceId: instance.id, port: state.port, remoteClientVerified: false });
521
- return;
522
- }
363
+ throw Error('INSTANCE_COMMAND_UNSUPPORTED');
523
364
  }
524
- //# sourceMappingURL=instances.js.map
@@ -71,4 +71,3 @@ export async function oauthCommand(args) {
71
71
  process.stdout.write(' Configuration saved. Enable explicitly: starmux auth mode oauth\n');
72
72
  process.stdout.write(' Remote ChatGPT authorization is not verified by this command.\n\n');
73
73
  }
74
- //# sourceMappingURL=oauth.js.map