handmux 0.27.2 → 0.29.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 (78) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +78 -23
  5. package/dist/hooks/handmux-write.cjs +11 -0
  6. package/dist/package.json +13 -13
  7. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  8. package/dist/public/assets/index-D0tDAqO-.js +356 -0
  9. package/dist/public/index.html +2 -2
  10. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  11. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  12. package/dist/src/agent-runtime/conversation.js +3 -3
  13. package/dist/src/agent-runtime/conversationStore.js +2 -2
  14. package/dist/src/agent-runtime/inbox.js +20 -2
  15. package/dist/src/agent-runtime/run.js +5 -1
  16. package/dist/src/agents/claudeConversation.js +69 -6
  17. package/dist/src/agents/claudeLocalCommand.js +54 -0
  18. package/dist/src/agents/claudeNativeTail.js +271 -0
  19. package/dist/src/agents/claudePaneInput.js +47 -0
  20. package/dist/src/agents/codexConversation.js +46 -0
  21. package/dist/src/agents/piInboxBridge.js +64 -5
  22. package/dist/src/agents/processIdentity.js +13 -6
  23. package/dist/src/apiAccountProviders.js +5 -4
  24. package/dist/src/browser/bootstrap.js +5 -0
  25. package/dist/src/browser/credentials.js +68 -0
  26. package/dist/src/browser/manager.js +40 -5
  27. package/dist/src/browser/publicProxy.js +15 -5
  28. package/dist/src/browser/routes.js +7 -0
  29. package/dist/src/browser/workerClient.js +174 -9
  30. package/dist/src/browser/workerServer.js +13 -1
  31. package/dist/src/claudeEvents.js +94 -38
  32. package/dist/src/cli/authCmd.js +202 -0
  33. package/dist/src/cli/authDefaults.js +18 -0
  34. package/dist/src/cli/claudeHooks.js +3 -3
  35. package/dist/src/cli/hookScaffold.js +5 -1
  36. package/dist/src/cli/i18n/en.js +66 -11
  37. package/dist/src/cli/i18n/zh.js +66 -11
  38. package/dist/src/cli/options.js +7 -13
  39. package/dist/src/cli/pushCmd.js +24 -11
  40. package/dist/src/cli/setupModel.js +28 -6
  41. package/dist/src/cli/setupWizard.js +47 -25
  42. package/dist/src/cli/shortcutEditor.js +20 -2
  43. package/dist/src/cli/supervisor.js +5 -7
  44. package/dist/src/codexAppServer.js +25 -15
  45. package/dist/src/codexTranscriptParse.js +40 -1
  46. package/dist/src/codexUsageSnapshot.js +1 -1
  47. package/dist/src/deviceAccess.js +134 -0
  48. package/dist/src/deviceAuth/control.js +154 -0
  49. package/dist/src/deviceAuth/http.js +321 -0
  50. package/dist/src/deviceAuth/service.js +626 -0
  51. package/dist/src/docs.js +3 -0
  52. package/dist/src/httpApi.js +8 -3
  53. package/dist/src/paneInput.js +16 -2
  54. package/dist/src/previewServer.js +26 -2
  55. package/dist/src/previews.js +37 -13
  56. package/dist/src/projectTask/backup.js +28 -0
  57. package/dist/src/projectTask/lock.js +1 -1
  58. package/dist/src/projectTask/migrations.js +30 -1
  59. package/dist/src/projectTask/runtime.js +7 -1
  60. package/dist/src/projectTask/schema.js +2 -2
  61. package/dist/src/push.js +44 -13
  62. package/dist/src/requestAuthority.js +8 -0
  63. package/dist/src/requestOrigin.js +62 -0
  64. package/dist/src/routes/agents.js +3 -1
  65. package/dist/src/routes/files.js +2 -0
  66. package/dist/src/routes/previews.js +9 -3
  67. package/dist/src/routes/push.js +55 -58
  68. package/dist/src/routes/system.js +4 -4
  69. package/dist/src/routes/terminal.js +9 -1
  70. package/dist/src/server.js +88 -12
  71. package/dist/src/terminalStream.js +90 -5
  72. package/dist/src/tmux/commands.js +2 -0
  73. package/dist/src/transcriptParse.js +4 -1
  74. package/dist/src/workspace/environment.js +14 -5
  75. package/dist/src/workspace/runtime.js +16 -1
  76. package/package.json +4 -4
  77. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  78. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
@@ -1,5 +1,47 @@
1
1
  // 中文字典。键与 en.js 一一对应;缺键会自动回退到英文。命令名、flag、隧道名等字面量保持英文(它们是要照抄输入的)。
2
2
  export default {
3
+ 'auth.section': '认证与安全',
4
+ 'auth.trusted': '可信设备保护',
5
+ 'auth.token': 'Token 登录',
6
+ 'auth.manageHint': '保护开关只能通过 CLI 管理;设备和访问地址可在 Web 设置中查看和管理。',
7
+ 'auth.noDevices': '还没有已授权设备。请在目标浏览器打开 Handmux,再在这台电脑运行 handmux auth device add。',
8
+ 'auth.access': '设备授权:先在目标浏览器打开 Handmux,再在这台电脑运行 handmux auth device add,并按提示确认。',
9
+ 'auth.warning': '⚠ 可信设备保护未开启:当前只凭 Token 即可访问,建议运行 handmux auth device on 开启。',
10
+ 'auth.addressWarning': '⚠ 可信地址保护未开启:任何地址都可以尝试连接,建议运行 handmux auth address on 开启。',
11
+ 'auth.deviceDisableConfirm': '⚠ 关闭可信设备保护会增加安全风险:之后只需 Token 即可访问。确认要关闭吗?',
12
+ 'auth.addressDisableConfirm': '⚠ 关闭可信地址保护会增加安全风险:任何地址都可以尝试连接。确认要关闭吗?',
13
+ 'auth.code': '请输入目标浏览器显示的 6 位验证码',
14
+ 'auth.codeInvalid': '请输入正好 6 位数字。',
15
+ 'auth.claimed': '已找到这次授权申请。请在 5 分钟内设置设备名称和有效期,确认后浏览器才能登录。',
16
+ 'auth.origin': '访问地址',
17
+ 'auth.name': '设备名称',
18
+ 'auth.expire': '授权有效期',
19
+ 'auth.custom': '自定义',
20
+ 'auth.never': '不限期',
21
+ 'auth.duration': '请输入有效期,例如 1h、7d、30d,或 never',
22
+ 'auth.confirm': '确认授权这台浏览器吗?',
23
+ 'auth.canceled': '已取消本次授权。',
24
+ 'auth.safety': '只确认你正在添加的浏览器;不要输入别人发来的验证码。',
25
+ 'auth.invalidName': '设备名称需为 1–80 个字符,且不能包含控制字符。',
26
+ 'auth.invalidDuration': '请输入有效期,例如 1h、7d、30d 或 never。',
27
+ 'auth.error.unavailable': '无法连接 Handmux 授权服务,请确认 Handmux 正在运行后重试。',
28
+ 'auth.error.invalidCommand': '授权操作无效,请重试。',
29
+ 'auth.error.codeInvalid': '验证码无效、已过期或已使用,请在浏览器重新申请。',
30
+ 'auth.error.rateLimit': '验证码尝试次数过多,请稍后再试。',
31
+ 'auth.error.pairingGone': '这次授权申请已不存在或已过期,请在浏览器重新申请验证码。',
32
+ 'auth.error.deviceNotFound': '找不到这个设备,请先运行 handmux auth device list 查看设备 ID。',
33
+ 'auth.error.deviceInactive': '设备已过期或解除绑定,请重新授权。',
34
+ 'auth.error.conflict': '设备信息已被其他操作修改,请重新查看设备后再试。',
35
+ 'auth.error.invalidEdit': '请至少提供 --name 或 --expire。',
36
+ 'auth.error.invalidVersion': '设备信息已更新,请重新查看设备后再试。',
37
+ 'auth.error.pairingCapacity': '待处理的授权申请太多,请稍后再试。',
38
+ 'auth.error.tokenRequired': '请先提供 Token,再进行授权操作。',
39
+ 'auth.error.deviceRequired': '请先授权这台浏览器,再开启可信设备保护。',
40
+ 'auth.error.expiryCliOnly': '设备有效期只能通过服务器上的 handmux CLI 修改。',
41
+ 'auth.error.invalidOrigin': '访问地址格式不正确,请提供完整的 http(s) 地址。',
42
+ 'auth.error.originLimit': '可信访问地址已达到上限,请先删除一个再添加。',
43
+ 'auth.error.sessionInvalid': '当前设备授权已失效,请重新配对。',
44
+ 'auth.error.controlFailed': '授权操作未完成,请检查 Handmux 状态后重试。',
3
45
  // 通用
4
46
  'err.generic': '✗ {msg}',
5
47
  'err.configNotFound': '✗ --config {path}:找不到该文件',
@@ -107,7 +149,7 @@ export default {
107
149
  'access.pending': '(等待中…)',
108
150
  'access.lan': ' 📶 局域网 {url}',
109
151
  'access.local': ' 💻 本机 {url}',
110
- 'access.token': ' 🔑 令牌 {token}',
152
+ 'access.token': ' 🔑 Token 登录 {token}',
111
153
  'access.reachable': ' ✓ 可访问',
112
154
  'access.unreachable': ' ⚠ 隧道已起,但 {url} 没有响应 —— 检查服务端的反向代理 / DNS',
113
155
  'access.hint': ' handmux status | stop',
@@ -178,6 +220,7 @@ export default {
178
220
  'setup.askSshHost': 'ssh 主机(user@host[:port])',
179
221
  'setup.askRemotePort': 'ssh 主机上的远程端口',
180
222
  'setup.askPublicUrl': '公网地址,http(s):// 按情况填(留空 = http://host:remotePort)',
223
+ 'setup.askDirectPublicUrl': '自定义访问地址(填你自己的反向隧道或代理地址;留空 = 仅本机 / 局域网直连)',
181
224
  'setup.natappGuide': '去哪拿 authtoken:到 https://natapp.cn 免费注册 → 新建一条隧道 → 复制它的 authtoken(免费额度够上手)。',
182
225
  'setup.cpolarGuide': '去哪拿 authtoken:到 https://cpolar.com 免费注册 → 打开后台 → 验证/Verify → 复制你的 authtoken。',
183
226
  'setup.askAuthtoken': 'authtoken',
@@ -193,19 +236,19 @@ export default {
193
236
  'setup.secConnection': '连接',
194
237
  'setup.secName': '名称',
195
238
  'setup.secPort': '端口',
196
- 'setup.secToken': '令牌',
239
+ 'setup.secToken': 'Token 登录',
197
240
  'setup.secBrowser': '网页预览器',
198
241
  'setup.browserOff': '未配置 · 仅手机直连',
199
242
  'setup.askBrowserDomain': '网页预览器代理域名(留空 = 仅手机直连)',
200
243
  'setup.browserAbout': '填写代理域名(如 preview.example.com),并将其通配子域以 HTTPS 路由到 Handmux;留空则仅使用手机直连。',
201
- 'setup.tokenAuto': '自动 · 每次启动新生成',
202
- 'setup.tokenCustom': '自定义令牌…',
203
- 'setup.tokenRandom': '随机生成一个',
204
- 'setup.tokenReset': '恢复自动(每次启动新生成)',
205
- 'setup.askToken': '访问令牌 —— 会出现在手机打开的网址里',
206
- 'setup.tokenGenerated': '新令牌:{token}',
207
- 'setup.valToken': '请输入令牌',
208
- 'setup.valTokenSpace': '不能有空格 —— 令牌会放进网址',
244
+ 'setup.tokenAuto': '已保存(Token 不会自动更换)',
245
+ 'setup.tokenCustom': '修改Token…',
246
+ 'setup.tokenRandom': '生成并保存新的随机 Token',
247
+ 'setup.tokenReset': 'Token 不会自动更换',
248
+ 'setup.askToken': 'Token —— 与可信设备同时用于登录',
249
+ 'setup.tokenGenerated': '新的Token:{token}',
250
+ 'setup.valToken': '请输入Token',
251
+ 'setup.valTokenSpace': '不能有空格 —— 请在登录页单独输入 Token',
209
252
  'setup.secLanguage': '命令行语言 / CLI language',
210
253
  'setup.secPush': '推送',
211
254
  'setup.secVoice': '语音',
@@ -235,6 +278,7 @@ export default {
235
278
  'setup.valRequired': '{label} 不能为空',
236
279
  'setup.valHost': '请输入有效域名(如 myapp.example.com)',
237
280
  'setup.valPreviewDomain': '只填域名,如 preview.example.com;不要填 http://、https://、端口或 *.',
281
+ 'setup.valPublicUrl': '请输入完整的 http(s) 地址,例如 https://handmux.example.com(不要带路径或凭据)',
238
282
  'setup.valContact': '请填 mailto:you@example.com 或 https://你的站点(要真实域名 —— 苹果会拒收假域名)',
239
283
  'setup.sumTemp': '临时',
240
284
  'setup.sumFixed': '固定',
@@ -247,6 +291,7 @@ export default {
247
291
  'setup.connSshHost': 'SSH 主机',
248
292
  'setup.connRemotePort': '远程端口',
249
293
  'setup.connPublicUrl': '公网地址',
294
+ 'setup.connDirectAuto': '(留空 = 本机 / 局域网直连)',
250
295
  'setup.connJump': '跳板机',
251
296
  'setup.connDomain': '域名',
252
297
  'setup.connRegion': '区域',
@@ -356,6 +401,16 @@ export default {
356
401
  handmux stop | restart | status
357
402
  handmux logs [--follow] [--lines N]
358
403
  handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
404
+ handmux auth device status 查看设备保护状态和设备列表
405
+ handmux auth device on|off 开启或关闭设备保护
406
+ handmux auth device add 交互式授权浏览器(自动化使用 --code、--name 和 --expire)
407
+ handmux auth device list 列出完整设备 ID、名称、有效期和访问时间
408
+ handmux auth device edit <id> 修改设备名称或有效期
409
+ handmux auth device revoke <id> 立即撤销指定设备
410
+ handmux auth address status 查看可信地址保护状态和地址列表
411
+ handmux auth address on|off 开启或关闭可信地址保护
412
+ handmux auth address add <origin> 添加访问地址
413
+ handmux auth address remove <origin> 删除访问地址
359
414
  handmux codex [参数...] 启动与对话视图同步的 Codex TUI
360
415
  handmux pi [参数...] 原样传递参数并启动 Pi
361
416
  handmux agent [list] 查看支持的 Agent 接入状态
@@ -383,7 +438,7 @@ start flag(括号内为对应环境变量):
383
438
  --tunnel none|cloudflare|cloudflare-named|ssh|natapp|cpolar 暴露方式(默认:none)
384
439
  --port N 服务端口(HANDMUX_PORT,默认:19999)
385
440
  --host H 绑定地址(HANDMUX_HOST,默认:0.0.0.0)
386
- --token S 鉴权令牌(HANDMUX_TOKEN,默认:每次启动自动生成)
441
+ --token S 鉴权令牌(HANDMUX_TOKEN,默认:复用已有或首次生成)
387
442
  --name "My Box" 浏览器标签 + 主屏图标里的应用名(HANDMUX_APP_NAME)
388
443
  --public-url URL 对外公布的公网地址(HANDMUX_PUBLIC_URL;任意隧道均可,包括自建的 none;
389
444
  ssh 默认 http://host:remotePort;natapp/cpolar 填你的固定/保留域名 ——
@@ -64,7 +64,7 @@ export function sshPublicFallback(sshHost, remotePort) {
64
64
  const host = sshHost.replace(/^[^@]*@/, '').replace(/:\d+$/, '');
65
65
  return `http://${host}:${remotePort}`;
66
66
  }
67
- export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen) {
67
+ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen = defaultGen, authDefaults = {}) {
68
68
  const pick = (key, ...fallbacks) => {
69
69
  for (const v of [flags[key], fileCfg[key], ...fallbacks])
70
70
  if (v !== undefined && v !== null)
@@ -82,7 +82,7 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
82
82
  port,
83
83
  name: optionalString(pick('name', env.HANDMUX_APP_NAME), 'name'),
84
84
  host: optionalString(pick('host', env.HANDMUX_HOST, '0.0.0.0'), 'host') ?? '0.0.0.0',
85
- token: optionalString(pick('token', env.HANDMUX_TOKEN), 'token') ?? gen(),
85
+ token: optionalString(pick('token', env.HANDMUX_TOKEN, authDefaults.token), 'token') ?? gen(),
86
86
  foreground: !!pick('foreground', false),
87
87
  qr: pick('qr', true) !== false,
88
88
  // Unified config — what used to live in .env. The supervisor injects these into the server child's
@@ -194,15 +194,9 @@ function resolvePublicUrl(flags, fileCfg, env, tunnel) {
194
194
  return env.HANDMUX_PUBLIC_URL;
195
195
  return null;
196
196
  }
197
- // Default token: 8 chars from a typing-friendly alphabet (lowercase + digits, look-alikes 0/o/1/l dropped)
198
- // so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
199
- // length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
200
- const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
197
+ // 192 random bits for new credentials; configured legacy tokens remain unchanged.
201
198
  export function defaultGen() {
202
- let s = '';
203
- for (let i = 0; i < 8; i++)
204
- s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
205
- return s;
199
+ return crypto.randomBytes(24).toString('base64url');
206
200
  }
207
201
  function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
208
202
  if (flags[key] != null)
@@ -216,7 +210,7 @@ function trace(flags, fileCfg, env, cfgPath, key, envKey, def) {
216
210
  // Build the rows for `handmux config`: the value each key WOULD resolve to plus where it came from.
217
211
  // Lenient (never throws — unlike resolveConfig — so a half-finished config still prints) and secret-safe
218
212
  // (token masked; push/voice shown only as on/off). Tunnel-specific rows appear only for the live tunnel.
219
- export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env) {
213
+ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = process.env, authDefaults = {}) {
220
214
  const rows = [];
221
215
  const mask = (value) => (String(value).length <= 8 ? '••••' : `••••${String(value).slice(-4)}`);
222
216
  const add = (key, traced, display) => {
@@ -230,8 +224,8 @@ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = pr
230
224
  add('name', name, name.value == null ? '(default)' : String(name.value));
231
225
  const lang = trace(flags, fileCfg, env, cfgPath, 'lang', 'HANDMUX_LANG', null);
232
226
  add('lang', lang, lang.value == null ? '(auto — shell locale)' : String(lang.value));
233
- const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', null);
234
- add('token', token, token.value == null ? '(generated each start)' : mask(token.value));
227
+ const token = trace(flags, fileCfg, env, cfgPath, 'token', 'HANDMUX_TOKEN', authDefaults.token ?? null);
228
+ add('token', token, token.value == null ? '(generated on first start)' : mask(token.value));
235
229
  // publicUrl honours the same cross-tunnel guard as resolveConfig (file value only when tunnel matches).
236
230
  const t = tunnel.value;
237
231
  let pub = trace(flags, fileCfg, env, cfgPath, 'publicUrl', 'HANDMUX_PUBLIC_URL', null);
@@ -1,9 +1,11 @@
1
+ import fs from 'node:fs';
1
2
  // `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — fire one
2
3
  // notification to the phone through the already-running server (loopback + the server's own token).
3
4
  // Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
4
5
  // runner so it unit-tests without spawning or real fetch.
5
6
  import { readState } from './state.js';
6
7
  import { sanitizeNotificationUrl } from '../urlPolicy.js';
8
+ import { authSocketPath, connectAuthControl } from '../deviceAuth/control.js';
7
9
  const collect = (acc, value) => acc.concat(String(value ?? '').split(',').map((item) => item.trim()).filter(Boolean));
8
10
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
11
  const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value) ? value : 0;
@@ -59,22 +61,33 @@ export async function runPush({ argv, home, fetchImpl = globalThis.fetch, log =
59
61
  return 1;
60
62
  }
61
63
  const st = readState(home);
62
- if (!st || typeof st.localUrl !== 'string' || !st.localUrl
63
- || typeof st.token !== 'string' || !st.token) {
64
+ if (!st || typeof st.localUrl !== 'string' || !st.localUrl || typeof st.token !== 'string' || !st.token) {
64
65
  err('handmux is not running — run `handmux start` first.');
65
66
  return 1;
66
67
  }
67
68
  try {
68
- const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
69
- method: 'POST',
70
- headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
71
- body: JSON.stringify(parsed),
72
- });
73
- if (!res.ok) {
74
- err(`push failed: ${res.status ?? 'unknown'}`);
75
- return 1;
69
+ let raw;
70
+ if (fs.existsSync(authSocketPath(home))) {
71
+ const control = await connectAuthControl(home);
72
+ try {
73
+ raw = await control.request({ op: 'push', body: parsed });
74
+ }
75
+ finally {
76
+ control.close();
77
+ }
78
+ }
79
+ else {
80
+ const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
81
+ method: 'POST',
82
+ headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
83
+ body: JSON.stringify(parsed),
84
+ });
85
+ if (!res.ok) {
86
+ err(`push failed: ${res.status ?? 'unknown'}`);
87
+ return 1;
88
+ }
89
+ raw = await res.json();
76
90
  }
77
- const raw = await res.json();
78
91
  const out = isRecord(raw) ? raw : {};
79
92
  if (out.configured === false) {
80
93
  err('push is not configured (no VAPID keys) — run `handmux setup`.');
@@ -75,8 +75,8 @@ export function findTunnelId(listJsonOut, name) {
75
75
  // The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
76
76
  // before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
77
77
  // old value instead of leaving a stale field behind. Anything NOT here (staticDir, uploadExts…) is
78
- // preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
79
- // round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
78
+ // preserved untouched. `token` IS owned so the Token can be changed from
79
+ // its sub-page and round-trips through answersFromConfig on every setup run.
80
80
  const WIZARD_KEYS = [
81
81
  'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
82
82
  'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
@@ -93,9 +93,15 @@ export function configFromAnswers(a) {
93
93
  if (a.name)
94
94
  cfg.name = a.name;
95
95
  if (a.token)
96
- cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
96
+ cfg.token = a.token;
97
97
  if (a.previewDomain)
98
98
  cfg.previewDomain = a.previewDomain;
99
+ // Direct mode can sit behind a user-managed reverse tunnel or proxy. Keep
100
+ // its public entry point in the same `publicUrl` field used by the built-in
101
+ // tunnel drivers so origin validation and the URL shown by `handmux start`
102
+ // use one source of truth.
103
+ if (a.tunnel === 'none' && a.publicUrl)
104
+ cfg.publicUrl = a.publicUrl;
99
105
  if (a.tunnel === 'ssh') {
100
106
  cfg.sshHost = a.sshHost;
101
107
  cfg.remotePort = a.remotePort;
@@ -138,7 +144,7 @@ export function answersFromConfig(config = {}) {
138
144
  const a = {
139
145
  lang: optionalString(cfg.lang) || getLocale(),
140
146
  name: optionalString(cfg.name) || '',
141
- token: optionalString(cfg.token) || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
147
+ token: optionalString(cfg.token) || '',
142
148
  previewDomain: optionalString(cfg.previewDomain) || '',
143
149
  tunnel: isTunnel(cfg.tunnel) ? cfg.tunnel : 'none',
144
150
  port: Number(cfg.port) || 19999,
@@ -215,6 +221,21 @@ export function validatePreviewDomain(v) {
215
221
  return undefined;
216
222
  return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valPreviewDomain');
217
223
  }
224
+ export function validatePublicUrl(v) {
225
+ const s = String(v || '').trim();
226
+ if (!s)
227
+ return undefined;
228
+ try {
229
+ const url = new URL(s);
230
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
231
+ || url.pathname !== '/' || url.search || url.hash)
232
+ return t('setup.valPublicUrl');
233
+ return undefined;
234
+ }
235
+ catch {
236
+ return t('setup.valPublicUrl');
237
+ }
238
+ }
218
239
  // VAPID subject: Apple (APNs) rejects a fake/.local domain with BadJwtToken, so require a real-looking
219
240
  // mailto:you@host.tld or an https:// URL and reject the known-bad .local. Keeps push from silently
220
241
  // failing on iOS. (Can't fully validate "real" client-side — this just catches the obvious footguns.)
@@ -226,8 +247,9 @@ export function validateContact(v) {
226
247
  return t('setup.valContact');
227
248
  return undefined;
228
249
  }
229
- // Access token: it rides in the phone's URL as ?token=…, so require something and reject whitespace (a space
230
- // would break the link). Length/charset are otherwise up to the user — a pinned token is used verbatim.
250
+ // The Token is entered separately on the login page and is never put in
251
+ // an address or QR code. Keep the value non-empty and whitespace-free so it
252
+ // can be copied without ambiguity.
231
253
  export function validateToken(v) {
232
254
  const s = String(v || '').trim();
233
255
  if (!s)
@@ -17,6 +17,7 @@ import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
17
17
  import { t, setLocale } from './i18n/index.js';
18
18
  import { intro, outro, note, cancel, select, text, password, confirm, ask, CANCELLED } from './prompt.js';
19
19
  import { PrivateStateStore } from '../privateStateStore.js';
20
+ import { installationAuthDefaults } from './authDefaults.js';
20
21
  const errorMessage = (error) => {
21
22
  if (error instanceof Error)
22
23
  return error.message;
@@ -29,15 +30,15 @@ const isTunnel = (value) => (typeof value === 'string' && TUNNELS.includes(value
29
30
  // The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
30
31
  // the interactive shell below; re-exported so their historical import path (this module) stays valid for
31
32
  // callers and tests.
32
- import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
33
- export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
33
+ import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validatePublicUrl, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
34
+ export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validatePublicUrl, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
34
35
  function readExisting(file) {
35
- try {
36
- return new PrivateStateStore(file).readStrict() || {};
37
- }
38
- catch {
36
+ if (!fs.existsSync(file))
39
37
  return {};
40
- }
38
+ const value = new PrivateStateStore(file).readStrict();
39
+ if (!value || typeof value !== 'object' || Array.isArray(value))
40
+ throw new Error('expected a JSON object');
41
+ return value;
41
42
  }
42
43
  // The hub. Pre-fills from the existing config so a re-run edits/switches rather than starts over; a brand-
43
44
  // new config first walks Connection, then everyone lands on the hub (edit any section, then Save/Start/Exit).
@@ -49,9 +50,20 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
49
50
  log.error(t('setup.needTty'));
50
51
  return null;
51
52
  }
52
- const existing = readExisting(target);
53
- const isNew = !existing || Object.keys(existing).length === 0;
53
+ let existing;
54
+ try {
55
+ existing = readExisting(target);
56
+ }
57
+ catch (error) {
58
+ log.error(t('err.badConfig', { path: target, msg: errorMessage(error) }));
59
+ return null;
60
+ }
61
+ const defaults = installationAuthDefaults(home, target);
62
+ const { isNew } = defaults;
54
63
  let a = answersFromConfig(existing);
64
+ // Token is a durable factor. If no token exists yet, mint it once and persist it in config.json.
65
+ if (!a.token)
66
+ a.token = process.env.HANDMUX_TOKEN ?? defaults.token ?? genToken();
55
67
  setLocale(a.lang);
56
68
  intro('handmux setup');
57
69
  // First run lands the cursor on "Save & start" — the essential step (connection) is done in onboarding and
@@ -67,6 +79,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
67
79
  a.lang = await editLanguage(a);
68
80
  note(t('setup.welcome'));
69
81
  a = await editConnection(a, { home, log });
82
+ await editAuth(a);
70
83
  }
71
84
  catch (e) {
72
85
  if (e !== CANCELLED)
@@ -81,7 +94,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
81
94
  { value: 'connection', label: t('setup.secConnection'), hint: summarizeConnection(a) },
82
95
  { value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
83
96
  { value: 'port', label: t('setup.secPort'), hint: String(a.port) },
84
- { value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
97
+ { value: 'auth', label: t('auth.section'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
85
98
  { value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
86
99
  { value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
87
100
  {
@@ -120,8 +133,8 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
120
133
  a.name = await editName(a);
121
134
  else if (choice === 'port')
122
135
  a.port = await editPort(a);
123
- else if (choice === 'token')
124
- a.token = await editToken(a);
136
+ else if (choice === 'auth')
137
+ await editAuth(a);
125
138
  else if (choice === 'browser')
126
139
  a.previewDomain = await editBrowserDomain(a);
127
140
  else if (choice === 'language')
@@ -158,6 +171,10 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
158
171
  // clack's footer only shows ↑/↓ + Enter, so append the Esc-backs-out hint to each section's entry prompt —
159
172
  // otherwise a user inside a section can't tell there's a way back to the hub.
160
173
  const withBack = (msg) => `${msg} ${t('setup.escBack')}`;
174
+ async function editAuth(a) {
175
+ note(t('auth.manageHint'));
176
+ a.token = await editToken(a);
177
+ }
161
178
  async function editLanguage(a) {
162
179
  const lang = await ask(select({
163
180
  message: withBack(t('setup.langQ')),
@@ -186,22 +203,19 @@ async function editBrowserDomain(a) {
186
203
  }));
187
204
  return String(value || '').trim().toLowerCase();
188
205
  }
189
- // The access token — the one secret in the phone's URL. Unset = the server mints a fresh one each start
190
- // (printed + QR'd), so the link changes every restart; pinning one keeps the same URL. A mini-hub (like
191
- // push/voice): type your own, generate + pin a strong random one, or reset back to auto. Editing custom
192
- // pre-fills the current value (so you can read it off); the hub hint masks it. Esc returns to the main hub,
193
- // keeping the choice. Returns the token string ('' = auto).
206
+ // Token is a durable factor. A mini-hub lets the user keep the
207
+ // current value, replace it with a custom value, or generate a new one. The
208
+ // hub hint masks it and Esc returns without changing it.
194
209
  async function editToken(a) {
195
- let token = a.token || '';
210
+ let token = a.token || genToken();
196
211
  for (;;) {
197
212
  let pick;
198
213
  try {
199
214
  pick = await ask(select({
200
215
  message: withBack(t('setup.secToken')),
201
216
  options: [
202
- { value: 'custom', label: t('setup.tokenCustom'), hint: token ? maskSecret(token) : t('setup.tokenAuto') },
217
+ { value: 'custom', label: t('setup.tokenCustom'), hint: maskSecret(token) },
203
218
  { value: 'random', label: t('setup.tokenRandom') },
204
- { value: 'auto', label: t('setup.tokenReset'), hint: t('setup.tokenAuto') },
205
219
  ],
206
220
  initialValue: 'custom',
207
221
  }));
@@ -218,8 +232,6 @@ async function editToken(a) {
218
232
  token = genToken();
219
233
  note(t('setup.tokenGenerated', { token }));
220
234
  }
221
- else if (pick === 'auto')
222
- token = '';
223
235
  }
224
236
  catch (e) {
225
237
  if (e !== CANCELLED)
@@ -240,12 +252,18 @@ function tunnelOptions() {
240
252
  { value: 'cpolar', label: 'cpolar', hint: t('setup.hintCpolar') },
241
253
  ];
242
254
  }
243
- // Which tunnels have config fields to edit a level deeper (none/cloudflare-quick have nothing to configure).
244
- const hasConnFields = (tunnel) => ['cloudflare-named', 'ssh', 'natapp', 'cpolar'].includes(tunnel);
255
+ // Which tunnels have config fields to edit a level deeper. Direct mode has an
256
+ // optional public URL for a user-managed reverse tunnel; leaving it blank is
257
+ // still the ordinary LAN/local direct connection.
258
+ const hasConnFields = (tunnel) => ['none', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'].includes(tunnel);
245
259
  // The editable field rows for the CURRENT tunnel — the type/mode is chosen a level up (the picker), so this
246
260
  // lists ONLY that tunnel's config, values shown and secrets masked. Empty for none / cloudflare-quick.
247
261
  function connectionFieldRows(a) {
248
262
  const none = t('setup.connNone');
263
+ if (a.tunnel === 'none')
264
+ return [
265
+ { value: 'publicUrl', label: t('setup.connPublicUrl'), hint: a.publicUrl || t('setup.connDirectAuto') },
266
+ ];
249
267
  if (a.tunnel === 'cloudflare-named')
250
268
  return [
251
269
  { value: 'cfHostname', label: t('setup.connHostname'), hint: a.cfHostname || none },
@@ -345,7 +363,11 @@ async function editConnField(a, field) {
345
363
  n.remotePort = Number(await ask(text({ message: t('setup.askRemotePort'), initialValue: String(a.remotePort || a.port), validate: validatePort })));
346
364
  break;
347
365
  case 'publicUrl':
348
- setOpt('publicUrl', await ask(text({ message: t('setup.askPublicUrl'), initialValue: a.publicUrl || '' })));
366
+ setOpt('publicUrl', await ask(text({
367
+ message: t(a.tunnel === 'none' ? 'setup.askDirectPublicUrl' : 'setup.askPublicUrl'),
368
+ initialValue: a.publicUrl || '',
369
+ validate: validatePublicUrl,
370
+ })));
349
371
  break;
350
372
  case 'sshJump':
351
373
  setOpt('sshJump', await ask(text({ message: t('setup.askSshJump'), initialValue: a.sshJump || '' })));
@@ -1,8 +1,10 @@
1
+ import fs from 'node:fs';
1
2
  import { normalizeShortcuts, shortcutIdentity } from '../shortcutConfig.js';
2
3
  import { t } from './i18n/index.js';
3
4
  import * as prompts from './prompt.js';
4
5
  import { acquireLifecycleLock, isAlive, readState } from './state.js';
5
6
  import { PrivateStateStore } from '../privateStateStore.js';
7
+ import { authSocketPath, connectAuthControl } from '../deviceAuth/control.js';
6
8
  const MODIFIERS = {
7
9
  none: { prefixes: [], labels: [] },
8
10
  ctrl: { prefixes: ['C-'], labels: ['Ctrl'] },
@@ -281,8 +283,24 @@ export async function runShortcutEditor({ target, log = console, isTTY = process
281
283
  throw error;
282
284
  }
283
285
  }
284
- export async function applyShortcutsLive({ state, shortcuts, fetchImpl = globalThis.fetch, timeoutMs = 8000, }) {
286
+ export async function applyShortcutsLive({ state, shortcuts, home, fetchImpl = globalThis.fetch, timeoutMs = 8000, }) {
285
287
  const stateRecord = recordOf(state);
288
+ if (home && fs.existsSync(authSocketPath(home))) {
289
+ if (!home)
290
+ throw new Error('handmux home is required for the private control socket');
291
+ const control = await connectAuthControl(home);
292
+ const timer = setTimeout(() => control.close(), timeoutMs);
293
+ try {
294
+ const acknowledgment = await control.request({ op: 'shortcuts', body: { shortcuts } });
295
+ if (recordOf(acknowledgment)?.ok !== true)
296
+ throw new Error('invalid server response');
297
+ }
298
+ finally {
299
+ clearTimeout(timer);
300
+ control.close();
301
+ }
302
+ return;
303
+ }
286
304
  if (typeof stateRecord?.localUrl !== 'string' || !stateRecord.localUrl
287
305
  || typeof stateRecord.token !== 'string' || !stateRecord.token) {
288
306
  throw new Error('running server state is incomplete');
@@ -328,7 +346,7 @@ export async function commitShortcuts({ home, target, shortcuts, acquireLock = a
328
346
  if (!state || !isAliveImpl(state.supervisorPid))
329
347
  return { cfg, running: false, applied: false };
330
348
  try {
331
- await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, fetchImpl, timeoutMs });
349
+ await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, home, fetchImpl, timeoutMs });
332
350
  return { cfg, running: true, applied: true };
333
351
  }
334
352
  catch (error) {
@@ -34,11 +34,11 @@ export function lanUrl(port, ifaces = os.networkInterfaces()) {
34
34
  }
35
35
  return null;
36
36
  }
37
- // The token rides in the query string so the first navigation (or a QR scan) authenticates in one shot.
38
- export function publicUrlWithToken(base, token) {
39
- if (!base)
40
- return base;
41
- return `${base.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}`;
37
+ // Kept as a source-compatible helper for integrations that imported the old
38
+ // name. Credentials are deliberately never put in a URL; QR and CLI output
39
+ // carry only the address and the browser asks for the Token separately.
40
+ export function publicUrlWithToken(base, _token) {
41
+ return bareUrl(base);
42
42
  }
43
43
  // Bare address with no token in it — printed/QR-encoded so a link can be shared or screenshotted without
44
44
  // leaking the secret; the token is shown separately for the user to paste in.
@@ -80,8 +80,6 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
80
80
  error: null,
81
81
  };
82
82
  const persist = () => {
83
- // Keep the legacy top-level fields during migration, but derive them from the explicit component
84
- // machines so `ready` can never outlive the Server process that earned it.
85
83
  state.serverPid = components.server.pid;
86
84
  state.tunnelPid = components.tunnel.pid;
87
85
  state.ready = components.server.phase === 'ready';