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.
- package/bin/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- package/dist/public/assets/index-Cdrh2CUp.css +0 -32
|
@@ -15,7 +15,8 @@ export function previewRoutes({ previews }) {
|
|
|
15
15
|
if (typeof name !== 'string' || !name || typeof dir !== 'string' || !dir)
|
|
16
16
|
return res.status(400).json({ error: 'bad request' });
|
|
17
17
|
try {
|
|
18
|
-
const
|
|
18
|
+
const deviceId = res.locals.deviceAuth?.deviceId;
|
|
19
|
+
const out = await (deviceId ? previews.register({ name, dir }, deviceId) : previews.register({ name, dir }));
|
|
19
20
|
if ('error' in out)
|
|
20
21
|
return res.status(out.status).json({ error: out.error });
|
|
21
22
|
const url = `/preview/${encodeURIComponent(out.name)}/${encodeURIComponent(out.accessToken)}/`;
|
|
@@ -28,7 +29,8 @@ export function previewRoutes({ previews }) {
|
|
|
28
29
|
r.get('/previews', (_req, res) => {
|
|
29
30
|
if (!previews)
|
|
30
31
|
return res.status(503).json({ error: 'previews disabled' });
|
|
31
|
-
|
|
32
|
+
const deviceId = res.locals.deviceAuth?.deviceId;
|
|
33
|
+
return res.json({ previews: deviceId ? previews.list(deviceId) : previews.list() });
|
|
32
34
|
});
|
|
33
35
|
r.delete('/previews/:name', (req, res) => {
|
|
34
36
|
if (!previews)
|
|
@@ -36,7 +38,11 @@ export function previewRoutes({ previews }) {
|
|
|
36
38
|
const name = req.params.name;
|
|
37
39
|
if (!name || !safePreviewName(name))
|
|
38
40
|
return res.status(400).json({ error: 'bad name' });
|
|
39
|
-
|
|
41
|
+
const deviceId = res.locals.deviceAuth?.deviceId;
|
|
42
|
+
if (deviceId)
|
|
43
|
+
previews.remove(name, deviceId);
|
|
44
|
+
else
|
|
45
|
+
previews.remove(name);
|
|
40
46
|
return res.status(204).end();
|
|
41
47
|
});
|
|
42
48
|
return r;
|
package/dist/src/routes/push.js
CHANGED
|
@@ -8,7 +8,52 @@ const strings = (value) => Array.isArray(value)
|
|
|
8
8
|
? value.filter((item) => typeof item === 'string' && item.length > 0)
|
|
9
9
|
: [];
|
|
10
10
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
11
|
-
|
|
11
|
+
// Shared by the legacy route and private Unix control socket. Never grant general HTTP access to CLI.
|
|
12
|
+
export async function sendLocalPush({ push, notifications }, requestBody) {
|
|
13
|
+
const data = isRecord(requestBody) ? requestBody : {};
|
|
14
|
+
const { title, body, tag, url } = data;
|
|
15
|
+
const invalid = (message) => { throw Object.assign(new Error(message), { status: 400 }); };
|
|
16
|
+
if (typeof title !== 'string' || !title.trim())
|
|
17
|
+
return invalid('title required');
|
|
18
|
+
if (typeof body !== 'string' || !body.trim())
|
|
19
|
+
return invalid('body required');
|
|
20
|
+
const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
|
|
21
|
+
if (url != null && !safeUrl)
|
|
22
|
+
return invalid('url must be http(s) or relative');
|
|
23
|
+
const sessions = strings(data.sessions);
|
|
24
|
+
const devices = strings(data.devices);
|
|
25
|
+
if (sessions.length && devices.length)
|
|
26
|
+
return invalid('use --session or --device, not both');
|
|
27
|
+
const payload = { title, body };
|
|
28
|
+
if (typeof tag === 'string' && tag)
|
|
29
|
+
payload.tag = tag;
|
|
30
|
+
const opts = { urgency: 'normal', ttl: 1800 };
|
|
31
|
+
if (typeof payload.tag === 'string')
|
|
32
|
+
opts.topic = payload.tag;
|
|
33
|
+
let rec = null;
|
|
34
|
+
if (notifications) {
|
|
35
|
+
const targetKeys = push.resolveTargetKeys({ devices: devices.length ? devices : null, sessions: sessions.length ? sessions : null });
|
|
36
|
+
rec = notifications.record(targetKeys, { title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' } });
|
|
37
|
+
payload.data = { inboxId: rec.id };
|
|
38
|
+
}
|
|
39
|
+
const out = devices.length ? await push.sendToDevices(devices, payload, opts)
|
|
40
|
+
: sessions.length ? await push.sendToSessions(sessions, payload, opts) : await push.sendToAll(payload, opts);
|
|
41
|
+
if (notifications && rec) {
|
|
42
|
+
for (const delivery of out.deliveries || []) {
|
|
43
|
+
if (!delivery.pushKey)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const { deliveries: _deliveries, ...summary } = out;
|
|
54
|
+
return summary;
|
|
55
|
+
}
|
|
56
|
+
export function pushRoutes({ push, notifications, deviceAuth = false }) {
|
|
12
57
|
const r = express.Router();
|
|
13
58
|
// The client needs the VAPID public key to subscribe; 503 if the server has no keys configured.
|
|
14
59
|
r.get('/push/vapid', (_req, res) => {
|
|
@@ -26,7 +71,7 @@ export function pushRoutes({ push, notifications }) {
|
|
|
26
71
|
if (!sub)
|
|
27
72
|
return res.status(400).json({ error: 'bad subscription' });
|
|
28
73
|
try {
|
|
29
|
-
const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey);
|
|
74
|
+
const pushKey = push.addSubscription(sub, boundSessions, preferredPushKey, res.locals.deviceAuth?.deviceId);
|
|
30
75
|
if (!pushKey)
|
|
31
76
|
return res.status(400).json({ error: 'bad subscription' });
|
|
32
77
|
const delivery = await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
|
|
@@ -48,7 +93,7 @@ export function pushRoutes({ push, notifications }) {
|
|
|
48
93
|
r.post('/push/unsubscribe', (req, res) => {
|
|
49
94
|
const body = req.body;
|
|
50
95
|
const endpoint = isRecord(body) ? body.endpoint : undefined;
|
|
51
|
-
const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint) : null;
|
|
96
|
+
const pushKey = typeof endpoint === 'string' ? push.removeSubscription(endpoint, res.locals.deviceAuth?.deviceId) : null;
|
|
52
97
|
return res.json({ ok: true, pushKey });
|
|
53
98
|
});
|
|
54
99
|
// Manual "send me a test" — pushes to every stored subscription.
|
|
@@ -68,68 +113,20 @@ export function pushRoutes({ push, notifications }) {
|
|
|
68
113
|
const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
|
|
69
114
|
const boundSessions = strings(isRecord(requestBody) ? requestBody.boundSessions : undefined);
|
|
70
115
|
if (typeof endpoint === 'string')
|
|
71
|
-
push.updateBound(endpoint, boundSessions);
|
|
116
|
+
push.updateBound(endpoint, boundSessions, res.locals.deviceAuth?.deviceId);
|
|
72
117
|
return res.json({ ok: true });
|
|
73
118
|
});
|
|
74
119
|
// Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
|
|
75
120
|
// devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
|
|
76
121
|
r.post('/push/send-local', async (req, res, next) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const { title, body, tag, url } = data;
|
|
80
|
-
if (typeof title !== 'string' || !title.trim())
|
|
81
|
-
return res.status(400).json({ error: 'title required' });
|
|
82
|
-
if (typeof body !== 'string' || !body.trim())
|
|
83
|
-
return res.status(400).json({ error: 'body required' });
|
|
84
|
-
const safeUrl = url == null ? null : sanitizeNotificationUrl(url);
|
|
85
|
-
if (url != null && !safeUrl)
|
|
86
|
-
return res.status(400).json({ error: 'url must be http(s) or relative' });
|
|
87
|
-
const sessions = strings(data.sessions);
|
|
88
|
-
const devices = strings(data.devices);
|
|
89
|
-
const hasSessions = sessions.length > 0;
|
|
90
|
-
const hasDevices = devices.length > 0;
|
|
91
|
-
if (hasSessions && hasDevices)
|
|
92
|
-
return res.status(400).json({ error: 'use --session or --device, not both' });
|
|
93
|
-
const payload = { title, body };
|
|
94
|
-
if (typeof tag === 'string' && tag)
|
|
95
|
-
payload.tag = tag;
|
|
96
|
-
const opts = { urgency: 'normal', ttl: 1800 };
|
|
97
|
-
if (typeof payload.tag === 'string')
|
|
98
|
-
opts.topic = payload.tag;
|
|
99
|
-
// Record FIRST so the notification tap can deep-link to this exact message's detail page. `--url`
|
|
100
|
-
// is stored on the record (surfaced in the detail), NOT used as the tap target.
|
|
101
|
-
let targetKeys = [];
|
|
102
|
-
let rec = null;
|
|
103
|
-
if (notifications) {
|
|
104
|
-
targetKeys = push.resolveTargetKeys({ devices: hasDevices ? devices : null, sessions: hasSessions ? sessions : null });
|
|
105
|
-
rec = notifications.record(targetKeys, {
|
|
106
|
-
title, body, tag: payload.tag, url: safeUrl, delivery: { status: 'pending' },
|
|
107
|
-
});
|
|
108
|
-
payload.data = { inboxId: rec.id };
|
|
109
|
-
}
|
|
122
|
+
if (deviceAuth)
|
|
123
|
+
return res.status(404).json({ error: 'use the local handmux push CLI' });
|
|
110
124
|
try {
|
|
111
|
-
|
|
112
|
-
: hasSessions ? await push.sendToSessions(sessions, payload, opts)
|
|
113
|
-
: await push.sendToAll(payload, opts);
|
|
114
|
-
if (notifications && rec) {
|
|
115
|
-
for (const delivery of out.deliveries || []) {
|
|
116
|
-
if (!delivery.pushKey)
|
|
117
|
-
continue;
|
|
118
|
-
try {
|
|
119
|
-
notifications.updateDelivery(delivery.pushKey, rec.id, delivery);
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
// Delivery already happened. Do not turn a status-metadata write failure into an HTTP error:
|
|
123
|
-
// CLI automation would retry and send the real notification twice. The pending record remains
|
|
124
|
-
// honest (unknown final state) and the server log retains the storage failure for diagnosis.
|
|
125
|
-
console.warn(`[handmux] notification delivery status update failed: ${errorMessage(error)}`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
const { deliveries: _deliveries, ...summary } = out;
|
|
130
|
-
return res.json(summary);
|
|
125
|
+
return res.json(await sendLocalPush({ push, ...(notifications ? { notifications } : {}) }, req.body));
|
|
131
126
|
}
|
|
132
127
|
catch (e) {
|
|
128
|
+
if (isRecord(e) && e.status === 400)
|
|
129
|
+
return res.status(400).json({ error: errorMessage(e) });
|
|
133
130
|
return next(e);
|
|
134
131
|
}
|
|
135
132
|
});
|
|
@@ -139,7 +136,7 @@ export function pushRoutes({ push, notifications }) {
|
|
|
139
136
|
const endpoint = isRecord(requestBody) ? requestBody.endpoint : undefined;
|
|
140
137
|
if (typeof endpoint !== 'string')
|
|
141
138
|
return res.status(400).json({ error: 'endpoint required' });
|
|
142
|
-
return res.json({ pushKey: push.getPushKey(endpoint) });
|
|
139
|
+
return res.json({ pushKey: push.getPushKey(endpoint, res.locals.deviceAuth?.deviceId) });
|
|
143
140
|
});
|
|
144
141
|
return r;
|
|
145
142
|
}
|
|
@@ -40,9 +40,9 @@ const PKG_VERSION = (() => {
|
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
42
|
})();
|
|
43
|
-
export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, shortcuts, home, stateFile, previewDomain, agentIntegrationContext, }) {
|
|
43
|
+
export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, shortcuts, home, stateFile, previewDomain, agentIntegrationContext, shortcutState, }) {
|
|
44
44
|
const r = express.Router();
|
|
45
|
-
|
|
45
|
+
const activeShortcuts = shortcutState ?? { value: normalizeShortcuts(shortcuts) };
|
|
46
46
|
const integrationContext = agentIntegrationContext ?? defaultAgentIntegrationContext({
|
|
47
47
|
home,
|
|
48
48
|
piEntryFile: PI_ENTRY,
|
|
@@ -65,7 +65,7 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
|
|
|
65
65
|
asrFillerFilter: voiceAdapter?.capabilities?.fillerFilter === true,
|
|
66
66
|
claudeHooks: hooksStatus(home),
|
|
67
67
|
managedCodex: true,
|
|
68
|
-
shortcuts: activeShortcuts,
|
|
68
|
+
shortcuts: activeShortcuts.value,
|
|
69
69
|
browserProxy: !!previewDomain,
|
|
70
70
|
});
|
|
71
71
|
});
|
|
@@ -75,7 +75,7 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
|
|
|
75
75
|
return res.status(400).json({ error: 'shortcuts required' });
|
|
76
76
|
}
|
|
77
77
|
try {
|
|
78
|
-
activeShortcuts = normalizeShortcuts(body.shortcuts);
|
|
78
|
+
activeShortcuts.value = normalizeShortcuts(body.shortcuts);
|
|
79
79
|
}
|
|
80
80
|
catch (error) {
|
|
81
81
|
return res.status(400).json({ error: errorMessage(error) });
|
|
@@ -8,6 +8,7 @@ import { capTrailingBlankRows } from '../trimCapture.js';
|
|
|
8
8
|
import { isAllowedKey } from '../keyNames.js';
|
|
9
9
|
import { restoreCaptureBackgrounds } from '../captureBackground.js';
|
|
10
10
|
import { serializePaneInput } from '../paneInput.js';
|
|
11
|
+
import { assertRequestAuthority } from '../requestAuthority.js';
|
|
11
12
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
13
|
const requestBody = (req) => isRecord(req.body) ? req.body : {};
|
|
13
14
|
export { isAllowedKey } from '../keyNames.js';
|
|
@@ -104,10 +105,12 @@ export function terminalRoutes({ commands }) {
|
|
|
104
105
|
try {
|
|
105
106
|
await serializePaneInput(pane, async () => {
|
|
106
107
|
await commands.exitCopyModeIfActive(pane);
|
|
108
|
+
assertRequestAuthority();
|
|
107
109
|
await commands.sendText(pane, body);
|
|
108
110
|
if (enter) {
|
|
109
111
|
if (body)
|
|
110
112
|
await delay(SUBMIT_GAP_MS); // a bare Enter has nothing to settle — send at once
|
|
113
|
+
assertRequestAuthority();
|
|
111
114
|
await commands.sendEnter(pane);
|
|
112
115
|
}
|
|
113
116
|
});
|
|
@@ -126,6 +129,7 @@ export function terminalRoutes({ commands }) {
|
|
|
126
129
|
try {
|
|
127
130
|
await serializePaneInput(pane, async () => {
|
|
128
131
|
await commands.exitCopyModeIfActive(pane);
|
|
132
|
+
assertRequestAuthority();
|
|
129
133
|
await commands.sendHexInput(pane, hex);
|
|
130
134
|
});
|
|
131
135
|
return res.json({ ok: true });
|
|
@@ -175,6 +179,7 @@ export function terminalRoutes({ commands }) {
|
|
|
175
179
|
// restore the split arrangement (resizePane) then hand window sizing back (resizeWindow)
|
|
176
180
|
if (typeof layout === 'string' && layout)
|
|
177
181
|
await commands.applyWindowLayout(window, layout);
|
|
182
|
+
assertRequestAuthority();
|
|
178
183
|
await commands.restoreWindowSize(window);
|
|
179
184
|
}
|
|
180
185
|
else if (isPaneId(pane)) {
|
|
@@ -203,8 +208,10 @@ export function terminalRoutes({ commands }) {
|
|
|
203
208
|
try {
|
|
204
209
|
await serializePaneInput(pane, async () => {
|
|
205
210
|
await commands.exitCopyModeIfActive(pane);
|
|
206
|
-
for (const k of keys)
|
|
211
|
+
for (const k of keys) {
|
|
212
|
+
assertRequestAuthority();
|
|
207
213
|
await commands.sendKey(pane, k);
|
|
214
|
+
}
|
|
208
215
|
});
|
|
209
216
|
return res.json({ ok: true });
|
|
210
217
|
}
|
|
@@ -229,6 +236,7 @@ export function terminalRoutes({ commands }) {
|
|
|
229
236
|
const { altScreen, mouseAware, mouseSgr, width, height } = await commands.paneInfo(pane);
|
|
230
237
|
if (!altScreen || !mouseAware)
|
|
231
238
|
return res.json({ ok: false, reason: 'no-mouse' });
|
|
239
|
+
assertRequestAuthority();
|
|
232
240
|
await commands.sendWheel(pane, dir, Number(lines), {
|
|
233
241
|
sgr: mouseSgr,
|
|
234
242
|
col: Math.max(1, Math.floor((width || 2) / 2)),
|
package/dist/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
|
-
import {
|
|
6
|
+
import { loadToken } from './auth.js';
|
|
7
7
|
import { createApiRouter } from './httpApi.js';
|
|
8
8
|
import { loadUploadExts } from './uploadTypes.js';
|
|
9
9
|
import { createClaudeEvents } from './claudeEvents.js';
|
|
@@ -13,6 +13,14 @@ import { removeLegacyCodexHooks } from './cli/legacyCodexHooks.js';
|
|
|
13
13
|
import { agentRuntimeDirectoryPath, claudeStatePath, codexOutboxPath, } from './cli/state.js';
|
|
14
14
|
import * as commands from './tmux/commands.js';
|
|
15
15
|
import * as push from './push.js';
|
|
16
|
+
import * as notifications from './notifications.js';
|
|
17
|
+
import { sendLocalPush } from './routes/push.js';
|
|
18
|
+
import { DeviceAuthService } from './deviceAuth/service.js';
|
|
19
|
+
import { createDeviceAuthRouter } from './deviceAuth/http.js';
|
|
20
|
+
import { startDeviceAuthControl } from './deviceAuth/control.js';
|
|
21
|
+
import { createAuthOriginResolver, createDeviceAccess } from './deviceAccess.js';
|
|
22
|
+
import { readState } from './cli/state.js';
|
|
23
|
+
import { normalizeShortcuts } from './shortcutConfig.js';
|
|
16
24
|
import { cacheControlFor } from './staticCache.js';
|
|
17
25
|
import { compressStaticAssets } from './staticCompression.js';
|
|
18
26
|
import { applyAppName, applyManifestName } from './appName.js';
|
|
@@ -33,7 +41,8 @@ import { apiErrorBoundary, apiRequestContext } from './apiErrors.js';
|
|
|
33
41
|
import { RuntimeHealth } from './healthProtocol.js';
|
|
34
42
|
import { healthRoutes } from './routes/health.js';
|
|
35
43
|
import { createBuiltinAgentRuntime } from './agent-runtime/builtinRuntime.js';
|
|
36
|
-
import {
|
|
44
|
+
import { interruptClaudePane, sendPaneChoice } from './paneInput.js';
|
|
45
|
+
import { sendClaudePanePrompt } from './agents/claudePaneInput.js';
|
|
37
46
|
import { createLocalAgentProcessContext, TmuxAgentPaneSource, } from './agent-runtime/tmuxRuntime.js';
|
|
38
47
|
import { InboxPushProjection } from './agent-runtime/inboxPushProjection.js';
|
|
39
48
|
import { defaultGit } from './git.js';
|
|
@@ -67,8 +76,40 @@ const projectTask = await createProjectTaskRuntime({
|
|
|
67
76
|
},
|
|
68
77
|
});
|
|
69
78
|
if (projectTask.status().status === 'unavailable') {
|
|
70
|
-
|
|
79
|
+
throw new Error(`Authentication database unavailable: ${projectTask.status().error?.message ?? 'unknown error'}`);
|
|
71
80
|
}
|
|
81
|
+
const auth = new DeviceAuthService({ db: projectTask.requireDatabase(), token,
|
|
82
|
+
onSuccessfulWrite: () => projectTask.successfulWrite() });
|
|
83
|
+
const configuredPublicUrl = process.env.HANDMUX_PUBLIC_URL || null;
|
|
84
|
+
const runtimeAdvertisedUrl = () => {
|
|
85
|
+
const state = readState(home);
|
|
86
|
+
if (!state || state.serverPid !== process.pid || state.port !== cfg.port)
|
|
87
|
+
return configuredPublicUrl;
|
|
88
|
+
// In Direct mode the supervisor's LAN/local fallback is a display URL, not
|
|
89
|
+
// a configured Public URL. Keep the setting explicitly blank so the web
|
|
90
|
+
// page can distinguish it from a user-managed reverse-tunnel address.
|
|
91
|
+
return state.tunnel === 'none' && !configuredPublicUrl ? null : state.publicUrl ?? configuredPublicUrl;
|
|
92
|
+
};
|
|
93
|
+
const resolveAuthOrigin = createAuthOriginResolver({ port: cfg.port, host: cfg.host,
|
|
94
|
+
...(configuredPublicUrl ? { publicUrl: configuredPublicUrl } : {}),
|
|
95
|
+
...(previewDomain ? { previewDomain } : {}),
|
|
96
|
+
trustedOrigins: () => auth.trustedOrigins,
|
|
97
|
+
trustedOriginEnabled: () => auth.trustedOriginEnabled,
|
|
98
|
+
runtimePublicUrl: runtimeAdvertisedUrl,
|
|
99
|
+
});
|
|
100
|
+
const deviceAccess = createDeviceAccess({ service: auth, resolveOrigin: resolveAuthOrigin });
|
|
101
|
+
const authenticate = deviceAccess.middleware;
|
|
102
|
+
push.setDeviceAuthorization((id) => auth.isDeviceActive(id));
|
|
103
|
+
const shortcutState = { value: cfg.shortcuts };
|
|
104
|
+
const authControl = await startDeviceAuthControl({ service: auth, home,
|
|
105
|
+
handlePush: (body) => sendLocalPush({ push, notifications }, body),
|
|
106
|
+
handleShortcuts: async (body) => {
|
|
107
|
+
if (!body || typeof body !== 'object' || !('shortcuts' in body))
|
|
108
|
+
throw new Error('shortcuts required');
|
|
109
|
+
shortcutState.value = normalizeShortcuts(body.shortcuts);
|
|
110
|
+
return { ok: true };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
72
113
|
// One writer set is shared by background capture today and the restore runtime added on top of it. The
|
|
73
114
|
// lock is filesystem-backed because the CLI may restore while this daemon is alive in another process.
|
|
74
115
|
const workspaceStore = createWorkspaceStore({ home });
|
|
@@ -145,8 +186,8 @@ const agentRuntime = createBuiltinAgentRuntime({
|
|
|
145
186
|
...(conversationStartupBlockReason === undefined ? {} : { conversationStartupBlockReason }),
|
|
146
187
|
claudeEvents: events,
|
|
147
188
|
claudeConversationControl: {
|
|
148
|
-
sendPrompt: (paneId, text, guard) =>
|
|
149
|
-
interrupt: (paneId) =>
|
|
189
|
+
sendPrompt: (paneId, text, guard) => sendClaudePanePrompt(commands, paneId, text, () => events?.paneRestoredPrompt(paneId) ?? null, guard),
|
|
190
|
+
interrupt: (paneId) => interruptClaudePane(commands, paneId),
|
|
150
191
|
},
|
|
151
192
|
claudeInteractionControl: {
|
|
152
193
|
capturePlain: (paneId) => commands.capturePlain(paneId),
|
|
@@ -169,6 +210,7 @@ const agentRuntime = createBuiltinAgentRuntime({
|
|
|
169
210
|
},
|
|
170
211
|
});
|
|
171
212
|
const claudeInboxBridge = new ClaudeHookBridgeConnector({
|
|
213
|
+
nativeTail: events.nativeTail,
|
|
172
214
|
socketPath: agentRuntime.socketPath,
|
|
173
215
|
credentialFile: path.join(agentRuntimeDirectory, 'bridge-credential.json'),
|
|
174
216
|
stateDirectory: path.join(agentRuntimeDirectory, 'connectors', 'claude'),
|
|
@@ -190,7 +232,11 @@ const inboxPush = new InboxPushProjection({
|
|
|
190
232
|
});
|
|
191
233
|
inboxPush.start();
|
|
192
234
|
agentRuntime.start().catch((error) => {
|
|
193
|
-
|
|
235
|
+
const detail = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
236
|
+
console.warn(`[handmux] Agent Runtime unavailable\n${detail}`);
|
|
237
|
+
for (const entry of agentRuntime.health()) {
|
|
238
|
+
console.warn(`[handmux] Agent Runtime adapter ${entry.adapterId}: ${entry.availability}${entry.message ? ` — ${entry.message}` : ''}`);
|
|
239
|
+
}
|
|
194
240
|
});
|
|
195
241
|
claudeInboxBridge.start();
|
|
196
242
|
workspace.start().catch(() => { });
|
|
@@ -219,7 +265,7 @@ try {
|
|
|
219
265
|
catch { /* best effort — migration never fails startup */ }
|
|
220
266
|
// Static directory preview remains for folders without a web server. Arbitrary sites and local ports
|
|
221
267
|
// use the built-in browser below; previewDomain may provide its dedicated public origin.
|
|
222
|
-
const previews = createPreviews({ home });
|
|
268
|
+
const previews = createPreviews({ home, isDeviceActive: (id) => auth.isDeviceActive(id) });
|
|
223
269
|
const preview = createPreview({ previews });
|
|
224
270
|
const handmuxOrigin = (() => {
|
|
225
271
|
try {
|
|
@@ -229,8 +275,29 @@ const handmuxOrigin = (() => {
|
|
|
229
275
|
return `http://127.0.0.1:${cfg.port}`;
|
|
230
276
|
}
|
|
231
277
|
})();
|
|
232
|
-
const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin
|
|
278
|
+
const browserWorker = createBrowserWorkerClient({ appToken: token, previewDomain, handmuxOrigin,
|
|
279
|
+
deviceAuthorization: {
|
|
280
|
+
getDeviceId: (req) => deviceAccess.authenticate(req)?.deviceId ?? null,
|
|
281
|
+
isActive: (id) => auth.isDeviceActive(id),
|
|
282
|
+
isMainRequest: (req) => resolveAuthOrigin(req) !== null,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
auth.onRevoke((id) => { push.revokeDevice(id); });
|
|
286
|
+
auth.onRevoke((id) => { previews.revokeDevice(id); });
|
|
287
|
+
auth.onRevoke((id) => { browserWorker.revokeDevice(id); });
|
|
233
288
|
const app = express();
|
|
289
|
+
app.disable('x-powered-by');
|
|
290
|
+
app.use((_req, res, next) => {
|
|
291
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
292
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
293
|
+
next();
|
|
294
|
+
});
|
|
295
|
+
app.use('/api/auth', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, express.json({ limit: '16kb' }), createDeviceAuthRouter({
|
|
296
|
+
service: auth,
|
|
297
|
+
resolveOrigin: resolveAuthOrigin,
|
|
298
|
+
previewDomain,
|
|
299
|
+
resolvePublicUrl: runtimeAdvertisedUrl,
|
|
300
|
+
}));
|
|
234
301
|
app.use(healthRoutes({
|
|
235
302
|
health,
|
|
236
303
|
refresh: async () => {
|
|
@@ -243,10 +310,10 @@ app.use(healthRoutes({
|
|
|
243
310
|
// Browser proxy leases stay behind normal Handmux auth. Client-owned direct tabs never enter
|
|
244
311
|
// server state; proxy operations and all claimed Hammerhead paths use the isolated worker.
|
|
245
312
|
app.use(browserWorker.publicHandler);
|
|
246
|
-
app.use('/api/browser-proxy', apiRequestContext(),
|
|
313
|
+
app.use('/api/browser-proxy', apiRequestContext(), authenticate, express.json(), browserWorker.apiHandler);
|
|
247
314
|
app.use('/api', createApiRouter({
|
|
248
315
|
token, events, uploadExts, previews, shortcuts: cfg.shortcuts, workspace, previewDomain,
|
|
249
|
-
agentRuntime, projectTask, apiAccounts,
|
|
316
|
+
agentRuntime, projectTask, apiAccounts, authentication: authenticate, deviceAuth: true, shortcutState,
|
|
250
317
|
}));
|
|
251
318
|
app.use('/preview', preview.router);
|
|
252
319
|
app.use(preview.refererFallback);
|
|
@@ -275,6 +342,12 @@ if (appName) {
|
|
|
275
342
|
}
|
|
276
343
|
// index:false so the renamed shell below owns "/" too (otherwise static would serve the generic one).
|
|
277
344
|
app.use(compressStaticAssets);
|
|
345
|
+
// Only the main app owns this policy. Browser proxy documents use a separate origin and must be
|
|
346
|
+
// embeddable by handmux; static previews already set their own opaque-origin sandbox policy above.
|
|
347
|
+
app.use((_req, res, next) => {
|
|
348
|
+
res.setHeader('Content-Security-Policy', "base-uri 'self'; object-src 'none'; frame-ancestors 'self'");
|
|
349
|
+
next();
|
|
350
|
+
});
|
|
278
351
|
app.use(express.static(staticDir, {
|
|
279
352
|
index: false,
|
|
280
353
|
// Cache-Control policy lives in staticCache.js (unit-tested): index.html + sw.js are never cached
|
|
@@ -301,7 +374,9 @@ app.use(apiErrorBoundary());
|
|
|
301
374
|
const server = app.listen(cfg.port, cfg.host, () => {
|
|
302
375
|
console.log(`[handmux] listening on http://${cfg.host}:${cfg.port} (serving ${staticDir})`);
|
|
303
376
|
});
|
|
304
|
-
const terminalStream = createTerminalStream({ token, commands
|
|
377
|
+
const terminalStream = createTerminalStream({ token, commands,
|
|
378
|
+
deviceAuth: { service: auth, resolveOrigin: resolveAuthOrigin },
|
|
379
|
+
});
|
|
305
380
|
server.on('upgrade', (req, socket, head) => {
|
|
306
381
|
if (terminalStream.onUpgrade(req, socket, head))
|
|
307
382
|
return;
|
|
@@ -310,12 +385,13 @@ server.on('upgrade', (req, socket, head) => {
|
|
|
310
385
|
});
|
|
311
386
|
const shutdown = createGracefulShutdown({ events, workspace, browser: browserWorker, server });
|
|
312
387
|
const handleSignal = () => {
|
|
388
|
+
deviceAccess.close();
|
|
313
389
|
terminalStream.close();
|
|
314
390
|
codexApp.close();
|
|
315
391
|
inboxPush.close();
|
|
316
392
|
Promise.all([
|
|
317
393
|
claudeInboxBridge.close(),
|
|
318
|
-
projectTask.close(),
|
|
394
|
+
authControl.close().then(() => { auth.close(); }).finally(() => projectTask.close()),
|
|
319
395
|
agentRuntime.close(),
|
|
320
396
|
shutdown(),
|
|
321
397
|
]).catch(() => { });
|
|
@@ -302,9 +302,24 @@ export class PaneControlStream {
|
|
|
302
302
|
catch { /* already gone */ }
|
|
303
303
|
}
|
|
304
304
|
}
|
|
305
|
-
export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
305
|
+
export function createTerminalStream({ token, commands, spawnControl, deviceAuth, }) {
|
|
306
306
|
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_CLIENT_MESSAGE_BYTES });
|
|
307
307
|
const streams = new Set();
|
|
308
|
+
const principals = new WeakMap();
|
|
309
|
+
const origins = new WeakMap();
|
|
310
|
+
const socketStreams = new Map();
|
|
311
|
+
const unsubscribe = deviceAuth?.service.onRevoke((deviceId) => {
|
|
312
|
+
for (const ws of wss.clients) {
|
|
313
|
+
if (principals.get(ws)?.deviceId !== deviceId)
|
|
314
|
+
continue;
|
|
315
|
+
const stream = socketStreams.get(ws);
|
|
316
|
+
stream?.close();
|
|
317
|
+
if (stream)
|
|
318
|
+
streams.delete(stream);
|
|
319
|
+
socketStreams.delete(ws);
|
|
320
|
+
ws.close(4001, 'unauthorized');
|
|
321
|
+
}
|
|
322
|
+
});
|
|
308
323
|
const heartbeat = setInterval(() => {
|
|
309
324
|
for (const socket of wss.clients) {
|
|
310
325
|
const ws = socket;
|
|
@@ -321,12 +336,37 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
321
336
|
heartbeat.unref?.();
|
|
322
337
|
wss.on('connection', (socket) => {
|
|
323
338
|
const ws = socket;
|
|
339
|
+
let principal = principals.get(ws);
|
|
340
|
+
const stillAuthorized = () => {
|
|
341
|
+
if (!deviceAuth)
|
|
342
|
+
return true;
|
|
343
|
+
try {
|
|
344
|
+
return Boolean(principal && deviceAuth.service.isActive(principal));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
324
350
|
ws.isAlive = true;
|
|
325
|
-
ws.on('pong', () => {
|
|
351
|
+
ws.on('pong', () => {
|
|
352
|
+
if (!stillAuthorized()) {
|
|
353
|
+
ws.close(4001, 'unauthorized');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
ws.isAlive = true;
|
|
357
|
+
if (principal)
|
|
358
|
+
deviceAuth?.service.touch(principal);
|
|
359
|
+
});
|
|
326
360
|
const cancelSubscribeDeadline = startSubscribeDeadline(ws);
|
|
327
361
|
let authenticating = false;
|
|
328
362
|
let stream = null;
|
|
329
363
|
ws.on('message', async (raw, binary) => {
|
|
364
|
+
if (principal && !stillAuthorized()) {
|
|
365
|
+
ws.close(4001, 'unauthorized');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (principal)
|
|
369
|
+
deviceAuth?.service.touch(principal);
|
|
330
370
|
if (binary)
|
|
331
371
|
return;
|
|
332
372
|
let message;
|
|
@@ -363,7 +403,24 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
363
403
|
ws.close(1003, 'bad subscribe message');
|
|
364
404
|
return;
|
|
365
405
|
}
|
|
366
|
-
if (
|
|
406
|
+
if (deviceAuth) {
|
|
407
|
+
const origin = origins.get(ws) ?? '';
|
|
408
|
+
const tokenPrincipal = deviceAuth.service.authenticateToken?.(message.token, origin);
|
|
409
|
+
if (!tokenPrincipal) {
|
|
410
|
+
ws.close(4001, 'unauthorized');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Trusted-device protection is an independent policy. When it is off,
|
|
414
|
+
// the Token principal is sufficient and becomes the socket principal.
|
|
415
|
+
if (!principal && deviceAuth.service.trustedDeviceEnabled === false) {
|
|
416
|
+
principal = tokenPrincipal;
|
|
417
|
+
}
|
|
418
|
+
if (!principal || !stillAuthorized()) {
|
|
419
|
+
ws.close(4001, 'unauthorized');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (!tokenEquals(message.token ?? '', token)) {
|
|
367
424
|
ws.close(4001, 'unauthorized');
|
|
368
425
|
return;
|
|
369
426
|
}
|
|
@@ -371,13 +428,14 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
371
428
|
cancelSubscribeDeadline();
|
|
372
429
|
try {
|
|
373
430
|
const session = await commands.paneSession(message.pane);
|
|
374
|
-
if (ws.readyState !== 1)
|
|
431
|
+
if (ws.readyState !== 1 || !stillAuthorized())
|
|
375
432
|
return;
|
|
376
433
|
stream = new PaneControlStream({
|
|
377
434
|
ws, pane: message.pane, session,
|
|
378
435
|
...(spawnControl ? { spawnControl } : {}),
|
|
379
436
|
});
|
|
380
437
|
streams.add(stream);
|
|
438
|
+
socketStreams.set(ws, stream);
|
|
381
439
|
await stream.start();
|
|
382
440
|
}
|
|
383
441
|
catch {
|
|
@@ -387,6 +445,7 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
387
445
|
});
|
|
388
446
|
ws.on('close', () => {
|
|
389
447
|
cancelSubscribeDeadline();
|
|
448
|
+
socketStreams.delete(ws);
|
|
390
449
|
if (stream) {
|
|
391
450
|
stream.close();
|
|
392
451
|
streams.delete(stream);
|
|
@@ -403,11 +462,37 @@ export function createTerminalStream({ token, commands, spawnControl, }) {
|
|
|
403
462
|
}
|
|
404
463
|
if (pathname !== '/api/terminal-stream')
|
|
405
464
|
return false;
|
|
406
|
-
|
|
465
|
+
let principal = null;
|
|
466
|
+
let requestOrigin = null;
|
|
467
|
+
if (deviceAuth) {
|
|
468
|
+
try {
|
|
469
|
+
const origin = deviceAuth.resolveOrigin(req);
|
|
470
|
+
if (origin && req.headers.origin === origin) {
|
|
471
|
+
requestOrigin = origin;
|
|
472
|
+
principal = deviceAuth.service.authenticateRequest(req, origin);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
if (!requestOrigin || (!principal && !deviceAuth.service.tokenEnabled)) {
|
|
480
|
+
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
485
|
+
if (principal)
|
|
486
|
+
principals.set(ws, principal);
|
|
487
|
+
if (requestOrigin)
|
|
488
|
+
origins.set(ws, requestOrigin);
|
|
489
|
+
wss.emit('connection', ws, req);
|
|
490
|
+
});
|
|
407
491
|
return true;
|
|
408
492
|
};
|
|
409
493
|
const close = () => {
|
|
410
494
|
clearInterval(heartbeat);
|
|
495
|
+
unsubscribe?.();
|
|
411
496
|
for (const stream of streams)
|
|
412
497
|
stream.close();
|
|
413
498
|
streams.clear();
|
|
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import { parseTmuxRows, tmuxFormat } from './format.js';
|
|
4
4
|
import { TmuxPaneOutputCapture } from './paneOutputCapture.js';
|
|
5
|
+
import { assertRequestAuthority } from '../requestAuthority.js';
|
|
5
6
|
export const isPaneId = (value) => typeof value === 'string' && /^%\d+$/.test(value);
|
|
6
7
|
export const isWindowId = (value) => typeof value === 'string' && /^@\d+$/.test(value);
|
|
7
8
|
export const isSessionId = (value) => typeof value === 'string' && /^\$\d+$/.test(value);
|
|
@@ -21,6 +22,7 @@ export const isValidStartupCmd = (value) => typeof value === 'string' && value.l
|
|
|
21
22
|
});
|
|
22
23
|
export function runTmux(args) {
|
|
23
24
|
return new Promise((resolve, reject) => {
|
|
25
|
+
assertRequestAuthority();
|
|
24
26
|
execFile('tmux', args, { maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
25
27
|
if (err)
|
|
26
28
|
reject(new Error(stderr?.toString() || err.message));
|
|
@@ -214,7 +214,10 @@ export function createTranscriptParser() {
|
|
|
214
214
|
// bubbles) — the pushed `it.text` below is the untrimmed original, so real text's internal/
|
|
215
215
|
// leading/trailing whitespace is preserved verbatim.
|
|
216
216
|
if (it.type === 'text' && typeof it.text === 'string' && it.text.trim()) {
|
|
217
|
-
msgs.push({ i, role, type: 'text', text: it.text, ts
|
|
217
|
+
msgs.push({ i, role, type: 'text', text: it.text, ts,
|
|
218
|
+
...(o.type === 'user' && m.role === 'user' && o.promptSource === 'system'
|
|
219
|
+
&& record(o.origin)?.kind === 'task-notification' ? { source: 'task-notification' } : {}),
|
|
220
|
+
});
|
|
218
221
|
}
|
|
219
222
|
else if (it.type === 'thinking' && typeof it.thinking === 'string' && it.thinking) {
|
|
220
223
|
msgs.push({ i, role: 'assistant', type: 'thinking', text: it.thinking, ts });
|