handmux 0.17.3 → 0.17.4

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/public/index.html CHANGED
@@ -48,7 +48,7 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-Ddagv-Lc.js"></script>
51
+ <script type="module" crossorigin src="/assets/index-BfJTQWOA.js"></script>
52
52
  <link rel="stylesheet" crossorigin href="/assets/index-BT03K2MQ.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BT03K2MQ.css"></noscript>
53
53
  </head>
54
54
  <body>
@@ -324,5 +324,14 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
324
324
  return { sessionId, transcriptPath, cwd };
325
325
  }
326
326
 
327
- return { getStates, start, stop, paneSession };
327
+ // Agent identity is needed independently of session metadata: a Codex pane may not have a Claude-style
328
+ // transcript path, but the current Claude-only chat lens must still fail closed instead of cwd-falling
329
+ // back to an unrelated Claude jsonl. Untagged legacy hook rows are Claude.
330
+ function paneAgent(pane) {
331
+ const rec = readStateFile(file)[pane];
332
+ if (!rec || typeof rec !== 'object') return null;
333
+ return typeof rec.agent === 'string' && rec.agent ? rec.agent : 'claude';
334
+ }
335
+
336
+ return { getStates, start, stop, paneSession, paneAgent };
328
337
  }
@@ -3,6 +3,7 @@
3
3
  // Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
4
4
  // runner so it unit-tests without spawning or real fetch.
5
5
  import { readState } from './state.js';
6
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
6
7
 
7
8
  const collect = (acc, v) => acc.concat(String(v).split(',').map((s) => s.trim()).filter(Boolean));
8
9
 
@@ -23,11 +24,13 @@ export function parsePushArgs(argv) {
23
24
  const [title, body] = positional;
24
25
  if (!title || !body) return { error: 'usage: handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]' };
25
26
  if (sessions.length && devices.length) return { error: 'use --session or --device, not both' };
27
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
28
+ if (url != null && !safeUrl) return { error: '--url must be an http(s) URL or a relative path' };
26
29
  const out = { title, body };
27
30
  if (sessions.length) out.sessions = sessions;
28
31
  if (devices.length) out.devices = devices;
29
32
  if (tag) out.tag = tag;
30
- if (url) out.url = url;
33
+ if (safeUrl) out.url = safeUrl;
31
34
  return out;
32
35
  }
33
36
 
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import crypto from 'node:crypto';
10
10
  import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
11
+ import { sanitizeNotificationUrl } from './urlPolicy.js';
11
12
 
12
13
  const here = path.dirname(fileURLToPath(import.meta.url));
13
14
  const DIR = process.env.NOTIF_DIR || path.resolve(here, '../data/notifications');
@@ -24,7 +25,8 @@ const load = (file) => readJsonArray(file).filter((n) => n && typeof n.title ===
24
25
  export function record(pushKeys, { title, body, tag, url } = {}) {
25
26
  const rec = { id: genId(), ts: Date.now(), title: String(title ?? ''), body: String(body ?? '') };
26
27
  if (tag) rec.tag = String(tag);
27
- if (url) rec.url = String(url);
28
+ const safeUrl = sanitizeNotificationUrl(url);
29
+ if (safeUrl) rec.url = safeUrl;
28
30
  for (const key of pushKeys || []) {
29
31
  const file = fileFor(key);
30
32
  if (!file) continue;
@@ -5,6 +5,8 @@
5
5
  // Also handles dynamic preview: Host-based dispatch to loopback ports (HTTP + WS upgrade).
6
6
  import net from 'node:net';
7
7
  import http from 'node:http';
8
+ import https from 'node:https';
9
+ import tls from 'node:tls';
8
10
  import express from 'express';
9
11
  import { tokenEquals } from './auth.js';
10
12
  import { safePreviewName } from './previews.js';
@@ -57,7 +59,15 @@ function cookieScope(domain) {
57
59
  // Resolve the on-disk file for a request path under a preview: '' or '<dir>/' → its index.html.
58
60
  function fileFor(rest) { return (!rest || rest.endsWith('/')) ? `${rest}index.html` : rest; }
59
61
 
60
- export function createPreview({ previews, token, domain = null }) {
62
+ export function createPreview({
63
+ previews,
64
+ token,
65
+ domain = null,
66
+ httpRequest = http.request,
67
+ httpsRequest = https.request,
68
+ netConnect = net.connect,
69
+ tlsConnect = tls.connect,
70
+ }) {
61
71
  const router = express.Router();
62
72
  const cookieDomain = cookieScope(domain);
63
73
 
@@ -117,9 +127,16 @@ export function createPreview({ previews, token, domain = null }) {
117
127
  // its own root, so path/method/headers/body forward as-is. `host` is the loopback family the app was
118
128
  // found on at register time ('127.0.0.1' or '::1'); the Host header stays 127.0.0.1:<port> so a dev
119
129
  // server's host check (e.g. Vite, whose allowedHosts include localhost/127.0.0.1) is satisfied.
120
- function proxyHttp(port, host, req, res) {
130
+ function proxyHttp(port, host, protocol, req, res) {
121
131
  const headers = { ...req.headers, host: `127.0.0.1:${port}` };
122
- const up = http.request({ host: host || '127.0.0.1', port, method: req.method, path: req.url, headers }, (upRes) => {
132
+ const secure = protocol === 'https';
133
+ const request = secure ? httpsRequest : httpRequest;
134
+ const options = { host: host || '127.0.0.1', port, method: req.method, path: req.url, headers };
135
+ // Local HTTPS dev servers commonly use a self-signed certificate. The connection target is constrained
136
+ // to loopback by registration/probing, so accepting that certificate does not expand trust to a network
137
+ // host. SNI stays localhost because that is what development certificates are normally issued for.
138
+ if (secure) Object.assign(options, { servername: 'localhost', rejectUnauthorized: false });
139
+ const up = request(options, (upRes) => {
123
140
  res.writeHead(upRes.statusCode, upRes.headers);
124
141
  upRes.on('error', () => res.destroy()); // mid-stream upstream reset (after headers) → tear down the client socket
125
142
  upRes.pipe(res);
@@ -147,7 +164,7 @@ export function createPreview({ previews, token, domain = null }) {
147
164
  if (state === 'missing') return res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
148
165
  if (state === 'expired') return res.status(410).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览已过期</h1><p>请回到 app 重新启动预览。</p>');
149
166
  if (entry.kind !== 'dynamic') return res.status(404).end(); // a static name reached via subdomain — not served here
150
- proxyHttp(entry.port, entry.host, req, res);
167
+ proxyHttp(entry.port, entry.host, entry.protocol, req, res);
151
168
  }
152
169
 
153
170
  // WebSocket (and any raw Upgrade) for a dynamic preview: same cookie auth, then a bare TCP pipe to
@@ -165,7 +182,13 @@ export function createPreview({ previews, token, domain = null }) {
165
182
  if (!credOk({ query, headers: req.headers }, token)) return socket.destroy();
166
183
  const { state, entry } = previews.get(name);
167
184
  if (state !== 'active' || entry.kind !== 'dynamic') return socket.destroy();
168
- const up = net.connect(entry.port, entry.host || '127.0.0.1', () => {
185
+ const secure = entry.protocol === 'https';
186
+ const options = { port: entry.port, host: entry.host || '127.0.0.1' };
187
+ if (secure) Object.assign(options, {
188
+ servername: 'localhost', rejectUnauthorized: false, ALPNProtocols: ['http/1.1'],
189
+ });
190
+ const connect = secure ? tlsConnect : netConnect;
191
+ const up = connect(options, () => {
169
192
  const fwd = { ...req.headers, host: `127.0.0.1:${entry.port}` };
170
193
  up.write(`GET ${req.url} HTTP/1.1\r\n`);
171
194
  for (const [k, v] of Object.entries(fwd)) up.write(`${k}: ${v}\r\n`);
package/src/previews.js CHANGED
@@ -69,16 +69,17 @@ export function createPreviews({
69
69
  return { name: entry.name, kind: entry.kind, expiresAt: entry.expiresAt };
70
70
  };
71
71
 
72
- async function register({ name, dir, port }) {
72
+ async function register({ name, dir, port, protocol = 'http' }) {
73
73
  const nm = safePreviewName(name);
74
74
  if (!nm) return { error: 'bad name', status: 400 };
75
75
  if (port !== undefined && port !== null && port !== '') {
76
76
  if (!dynamicEnabled) return { error: 'dynamic disabled', status: 400 };
77
+ if (protocol !== 'http' && protocol !== 'https') return { error: 'bad protocol', status: 400 };
77
78
  const p = Number(port);
78
79
  if (!Number.isInteger(p) || p < 1 || p > 65535) return { error: 'bad port', status: 400 };
79
80
  const host = await probePort(p); // '127.0.0.1' | '::1' | null
80
81
  if (!host) return { error: 'port not listening', status: 400 };
81
- return upsert({ name: nm, kind: 'dynamic', port: p, host });
82
+ return upsert({ name: nm, kind: 'dynamic', port: p, host, protocol });
82
83
  }
83
84
  if (typeof dir !== 'string' || dir[0] !== '/') return { error: 'not absolute', status: 400 };
84
85
  let real;
@@ -94,14 +95,17 @@ export function createPreviews({
94
95
  const entry = entries.find((e) => e && e.name === name);
95
96
  if (!entry) return { state: 'missing' };
96
97
  if (entry.expiresAt <= now()) { entries = entries.filter((e) => e.name !== name); flush(); return { state: 'expired' }; }
97
- return { state: 'active', entry: { kind: 'static', ...entry } }; // legacy rows (no kind) → static
98
+ const normalized = entry.kind === 'dynamic'
99
+ ? { ...entry, kind: 'dynamic', protocol: entry.protocol === 'https' ? 'https' : 'http' }
100
+ : { kind: 'static', ...entry }; // legacy rows (no kind) → static
101
+ return { state: 'active', entry: normalized };
98
102
  }
99
103
 
100
104
  function list() {
101
105
  const active = entries.filter((e) => e && e.expiresAt > now());
102
106
  if (active.length !== entries.length) { entries = active; flush(); }
103
107
  return active.map((e) => (e.kind === 'dynamic'
104
- ? { name: e.name, kind: 'dynamic', port: e.port, expiresAt: e.expiresAt }
108
+ ? { name: e.name, kind: 'dynamic', port: e.port, protocol: e.protocol === 'https' ? 'https' : 'http', expiresAt: e.expiresAt }
105
109
  : { name: e.name, kind: 'static', dir: e.dir, expiresAt: e.expiresAt }));
106
110
  }
107
111
 
@@ -10,12 +10,14 @@ export function previewRoutes({ previews, previewDomain, token }) {
10
10
  // dynamic reverse-proxy reachable at https://<name>.<DOMAIN>/ (only when previewDomain is set).
11
11
  r.post('/previews', async (req, res, next) => {
12
12
  if (!previews) return res.status(503).json({ error: 'previews disabled' });
13
- const { name, dir, port } = req.body || {};
13
+ const { name, dir, port, protocol } = req.body || {};
14
14
  if (typeof name !== 'string' || !name) return res.status(400).json({ error: 'bad request' });
15
15
  const hasPort = port !== undefined && port !== null && port !== '';
16
16
  if (!hasPort && (typeof dir !== 'string' || !dir)) return res.status(400).json({ error: 'bad request' });
17
17
  try {
18
- const out = await previews.register(hasPort ? { name, port } : { name, dir });
18
+ const dynamic = { name, port };
19
+ if (protocol !== undefined) dynamic.protocol = protocol;
20
+ const out = await previews.register(hasPort ? dynamic : { name, dir });
19
21
  if (out.error) return res.status(out.status).json({ error: out.error });
20
22
  const url = out.kind === 'dynamic'
21
23
  ? `https://${encodeURIComponent(out.name)}.${previewDomain}/?token=${encodeURIComponent(token)}`
@@ -1,6 +1,7 @@
1
1
  // Web Push routes: hand the client the VAPID key, store/update/remove a browser PushSubscription, and
2
2
  // the local script-push send entry. The push module owns the delivery contract (TTL/topic/prune).
3
3
  import express from 'express';
4
+ import { sanitizeNotificationUrl } from '../urlPolicy.js';
4
5
 
5
6
  export function pushRoutes({ push, notifications }) {
6
7
  const r = express.Router();
@@ -55,6 +56,8 @@ export function pushRoutes({ push, notifications }) {
55
56
  const { sessions, devices, title, body, tag, url } = req.body || {};
56
57
  if (typeof title !== 'string' || !title.trim()) return res.status(400).json({ error: 'title required' });
57
58
  if (typeof body !== 'string' || !body.trim()) return res.status(400).json({ error: 'body required' });
59
+ const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
60
+ if (url != null && !safeUrl) return res.status(400).json({ error: 'url must be http(s) or relative' });
58
61
  const hasSessions = Array.isArray(sessions) && sessions.length > 0;
59
62
  const hasDevices = Array.isArray(devices) && devices.length > 0;
60
63
  if (hasSessions && hasDevices) return res.status(400).json({ error: 'use --session or --device, not both' });
@@ -66,7 +69,7 @@ export function pushRoutes({ push, notifications }) {
66
69
  // is stored on the record (surfaced in the detail), NOT used as the tap target.
67
70
  if (notifications) {
68
71
  const targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
69
- const rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url });
72
+ const rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url: safeUrl });
70
73
  payload.data = { inboxId: rec.id };
71
74
  }
72
75
  try {
@@ -23,11 +23,24 @@ import { readClaudeContext } from '../usage.js';
23
23
  export function transcriptRoutes({ commands, claudeEvents }) {
24
24
  const r = express.Router();
25
25
 
26
+ // These three endpoints are one Claude-specific lens surface. Gate them together so a future endpoint
27
+ // cannot accidentally skip the invariant: explicit non-Claude requests and panes already bound to a
28
+ // non-Claude hook record are rejected before any cwd→newest-Claude-session fallback or screen scraping.
29
+ r.use(['/pending-prompt', '/context', '/transcript'], (req, res, next) => {
30
+ const pane = req.query.pane;
31
+ if (!isPaneId(pane)) return res.status(400).json({ error: 'bad pane id' });
32
+ const requested = req.query.agent;
33
+ const bound = claudeEvents && claudeEvents.paneAgent ? claudeEvents.paneAgent(pane) : null;
34
+ if ((requested != null && requested !== '' && requested !== 'claude') || (bound && bound !== 'claude')) {
35
+ return res.status(409).json({ error: 'chat lens unsupported for this agent' });
36
+ }
37
+ next();
38
+ });
39
+
26
40
  // The pending interactive PROMPT on the pane's screen — an AskUserQuestion menu or a tool-permission
27
41
  // menu — scraped from `capture-pane` (its options are NOT in the .jsonl while pending; see pendingPrompt.js).
28
42
  // Returns { prompt: {kind,title,options,cursor} | null }. Polled by the 对话 lens only while a gate is up.
29
43
  r.get('/pending-prompt', async (req, res, next) => {
30
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
31
44
  try {
32
45
  const text = await commands.capturePlain(req.query.pane);
33
46
  return res.json({ prompt: parsePendingPrompt(text) });
@@ -39,7 +52,6 @@ export function transcriptRoutes({ commands, claudeEvents }) {
39
52
  // { model, usedPercent } (either may be null: capturer not opted in / session hasn't rendered / no hooks).
40
53
  // The 对话 composer polls this to show a small "模型 · 24%" chip. Best-effort: never 500 on a missing file.
41
54
  r.get('/context', (req, res, next) => {
42
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
43
55
  try {
44
56
  const hooked = claudeEvents && claudeEvents.paneSession ? claudeEvents.paneSession(req.query.pane) : null;
45
57
  const sid = hooked && (hooked.sessionId || (hooked.transcriptPath ? path.basename(hooked.transcriptPath).replace(/\.jsonl$/, '') : null));
@@ -49,7 +61,6 @@ export function transcriptRoutes({ commands, claudeEvents }) {
49
61
  });
50
62
 
51
63
  r.get('/transcript', async (req, res, next) => {
52
- if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
53
64
  const limit = Math.min(Math.max(Number(req.query.limit) || 10, 1), 100);
54
65
  const before = req.query.before != null && req.query.before !== '' ? Number(req.query.before) : null;
55
66
  try {
@@ -0,0 +1,14 @@
1
+ // Notification links are user-supplied by `handmux push --url`. Keep relative in-app links, but reject
2
+ // executable/non-web schemes (javascript:, data:, file:, …) before they reach storage or an <a href>.
3
+ export function sanitizeNotificationUrl(value) {
4
+ if (typeof value !== 'string') return null;
5
+ const raw = value.trim();
6
+ if (!raw) return null;
7
+ try {
8
+ const parsed = new URL(raw, 'https://handmux.invalid/');
9
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
10
+ return raw;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }