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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +3 -3
- package/public/assets/{index-Ddagv-Lc.js → index-BfJTQWOA.js} +97 -97
- package/public/index.html +1 -1
- package/src/claudeEvents.js +10 -1
- package/src/cli/pushCmd.js +4 -1
- package/src/notifications.js +3 -1
- package/src/previewServer.js +28 -5
- package/src/previews.js +8 -4
- package/src/routes/previews.js +4 -2
- package/src/routes/push.js +4 -1
- package/src/routes/transcript.js +14 -3
- package/src/urlPolicy.js +14 -0
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-
|
|
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>
|
package/src/claudeEvents.js
CHANGED
|
@@ -324,5 +324,14 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
|
|
|
324
324
|
return { sessionId, transcriptPath, cwd };
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
|
|
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
|
}
|
package/src/cli/pushCmd.js
CHANGED
|
@@ -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 (
|
|
33
|
+
if (safeUrl) out.url = safeUrl;
|
|
31
34
|
return out;
|
|
32
35
|
}
|
|
33
36
|
|
package/src/notifications.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/previewServer.js
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/routes/previews.js
CHANGED
|
@@ -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
|
|
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)}`
|
package/src/routes/push.js
CHANGED
|
@@ -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 {
|
package/src/routes/transcript.js
CHANGED
|
@@ -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 {
|
package/src/urlPolicy.js
ADDED
|
@@ -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
|
+
}
|