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
package/dist/src/claudeEvents.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveVersionedComms } from './agents/claude.js';
|
|
|
6
6
|
import { resolveCodexComms } from './agents/codex.js';
|
|
7
7
|
import { defaultRun } from './agents/scanUtils.js';
|
|
8
8
|
import { claude } from './agents/claude.js';
|
|
9
|
+
import { ClaudeNativeTailReader } from './agents/claudeNativeTail.js';
|
|
9
10
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
11
|
// The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
|
|
11
12
|
// event { ts, src, host, payload }. The hook writes it (handmux-write.js); the server only reads it.
|
|
@@ -60,28 +61,6 @@ function defaultStatMtime(file) { try {
|
|
|
60
61
|
catch {
|
|
61
62
|
return null;
|
|
62
63
|
} }
|
|
63
|
-
// Read the LAST complete JSON line of a (possibly multi-MB) transcript, or null if unreadable. Reads only a
|
|
64
|
-
// bounded tail so cost is one small read regardless of transcript size; a last line longer than the window
|
|
65
|
-
// won't parse cleanly, but the only line we care to recognise (the interrupt marker) is tiny.
|
|
66
|
-
function defaultReadTail(file) {
|
|
67
|
-
try {
|
|
68
|
-
const fd = fs.openSync(file, 'r');
|
|
69
|
-
try {
|
|
70
|
-
const size = fs.fstatSync(fd).size;
|
|
71
|
-
const len = Math.min(size, 65536);
|
|
72
|
-
const buf = Buffer.alloc(len);
|
|
73
|
-
fs.readSync(fd, buf, 0, len, size - len);
|
|
74
|
-
const lines = buf.toString('utf8').split('\n').filter((line) => line.trim());
|
|
75
|
-
return lines.at(-1) ?? null;
|
|
76
|
-
}
|
|
77
|
-
finally {
|
|
78
|
-
fs.closeSync(fd);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
64
|
// Pure: has the user resolved the permission prompt recorded by `rec`, judged by its transcript mtime?
|
|
86
65
|
// True only once the transcript has grown past the event ts by more than the guard. Exported for testing.
|
|
87
66
|
export function permissionResolved(rec, mtimeMs, guard = PERM_RESOLVED_GUARD_MS) {
|
|
@@ -145,6 +124,14 @@ function readStateFile(file) {
|
|
|
145
124
|
src: typeof row.src === 'string' ? row.src : '',
|
|
146
125
|
payload,
|
|
147
126
|
};
|
|
127
|
+
if (typeof row.sequence === 'number' && Number.isSafeInteger(row.sequence) && row.sequence > 0) {
|
|
128
|
+
record.sequence = row.sequence;
|
|
129
|
+
}
|
|
130
|
+
const process = recordOf(row.process);
|
|
131
|
+
if (process && typeof process.pid === 'number' && Number.isSafeInteger(process.pid)
|
|
132
|
+
&& typeof process.startedAt === 'number' && Number.isFinite(process.startedAt)) {
|
|
133
|
+
record.process = { pid: process.pid, startedAt: process.startedAt };
|
|
134
|
+
}
|
|
148
135
|
if (Object.hasOwn(row, 'agent')) {
|
|
149
136
|
record.agent = typeof row.agent === 'string' && row.agent ? row.agent : null;
|
|
150
137
|
}
|
|
@@ -215,7 +202,7 @@ function managedStates(value) {
|
|
|
215
202
|
// file: the hook-maintained JSON state file (DEFAULT_STATE_FILE).
|
|
216
203
|
// The hook is the sole writer; the server reads the file fresh on every getStates and on every file
|
|
217
204
|
// change (the watcher, for push). No persisted state of our own — the file IS the persistence.
|
|
218
|
-
export function createClaudeEvents({ commands, push, codexApp = null, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail
|
|
205
|
+
export function createClaudeEvents({ nativeTail = new ClaudeNativeTailReader(), commands, push, codexApp = null, file = DEFAULT_STATE_FILE, now = () => Date.now(), statMtime = defaultStatMtime, readTail, run = defaultRun, onStateChange = () => { }, watch = fs.watch, mkdir = fs.mkdirSync, setTimer = setTimeout, clearTimer = (handle) => clearTimeout(handle), } = {}) {
|
|
219
206
|
// Short-lived executable-identity verdicts. Each poll still checks the foreground pid signature; the
|
|
220
207
|
// cache only avoids repeating lsof while that exact process set is unchanged.
|
|
221
208
|
const commVerdicts = new Map();
|
|
@@ -326,15 +313,23 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
|
|
|
326
313
|
if (!agent)
|
|
327
314
|
continue;
|
|
328
315
|
let c = classifyRecord(rec);
|
|
316
|
+
const native = rec.src === 'end' ? null : nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
|
|
317
|
+
if (native?.status === 'busy')
|
|
318
|
+
c = { kind: 'working', msg: '' };
|
|
319
|
+
else if (native?.status === 'waiting')
|
|
320
|
+
c = { kind: 'permission', msg: '' };
|
|
321
|
+
else if (native?.settled)
|
|
322
|
+
c = null;
|
|
329
323
|
// A 需要你 the user already resolved leaves no closing hook (see PERM_RESOLVED_GUARD_MS). statMtime
|
|
330
324
|
// gates the cheap "still pending" path; only once the transcript has grown past the event do we pay the
|
|
331
325
|
// bounded tail read to tell a RESUME (approve/deny → 进行中) from an INTERRUPT (ESC → neutral present).
|
|
332
|
-
if (c && c.kind === 'permission') {
|
|
326
|
+
if (!native?.status && c && c.kind === 'permission') {
|
|
333
327
|
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
334
|
-
if (tp && permissionResolved(rec, statMtime(tp)))
|
|
335
|
-
c = resolvedPermissionKind(readTail(tp));
|
|
328
|
+
if (tp && permissionResolved(rec, statMtime(tp))) {
|
|
329
|
+
c = readTail ? resolvedPermissionKind(readTail(tp)) : { kind: 'working', msg: '' };
|
|
330
|
+
}
|
|
336
331
|
}
|
|
337
|
-
else if (c && c.kind === 'working') {
|
|
332
|
+
else if (!native?.status && c && c.kind === 'working') {
|
|
338
333
|
// ESC-interrupt during a turn leaves the last hook as the stale 'prompt' (working) — no Stop fires,
|
|
339
334
|
// so working would otherwise stick until WORKING_TTL_MS (2h), pinning the composer's send→stop toggle.
|
|
340
335
|
// Once the transcript has grown past the prompt event, a bounded tail read settles it: an interrupt
|
|
@@ -342,18 +337,25 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
|
|
|
342
337
|
// definitive so no guard window is needed; an unreadable stat/tail can't tell → keep working.
|
|
343
338
|
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
344
339
|
const mtime = tp ? statMtime(tp) : null;
|
|
345
|
-
if (tp && typeof mtime === 'number' && mtime > rec.ts && isInterruptTail(readTail(tp)))
|
|
340
|
+
if (readTail && tp && typeof mtime === 'number' && mtime > rec.ts && isInterruptTail(readTail(tp)))
|
|
346
341
|
c = null;
|
|
347
342
|
}
|
|
348
|
-
else if (c && c.kind === 'compacting') {
|
|
343
|
+
else if (!native?.status && c && c.kind === 'compacting') {
|
|
349
344
|
// A no-op /compact fires no PostCompact; it writes its <local-command-stdout> at once. Once the
|
|
350
345
|
// transcript has grown past the PreCompact event and its tail is that stdout, the /compact is done
|
|
351
346
|
// (nothing to compact) → drop 压缩中. A real compaction stays silent until PostCompact, so it keeps
|
|
352
347
|
// showing for its whole run. Unreadable stat/tail → can't tell → keep 压缩中 (the TTL is the backstop).
|
|
353
348
|
const tp = typeof rec.payload.transcript_path === 'string' ? rec.payload.transcript_path : null;
|
|
354
349
|
const mtime = tp ? statMtime(tp) : null;
|
|
355
|
-
if (tp && typeof mtime === 'number' && mtime > rec.ts
|
|
356
|
-
|
|
350
|
+
if (tp && typeof mtime === 'number' && mtime > rec.ts) {
|
|
351
|
+
const last = native?.lastRecord;
|
|
352
|
+
const content = recordOf(last?.message)?.content;
|
|
353
|
+
const stdout = last?.sessionId === rec.payload.session_id && last?.isSidechain !== true
|
|
354
|
+
&& typeof last?.timestamp === 'string' && Date.parse(last.timestamp) > rec.ts
|
|
355
|
+
&& typeof content === 'string' && /^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/.test(content);
|
|
356
|
+
if (readTail ? isLocalCommandStdout(readTail(tp)) : stdout)
|
|
357
|
+
c = null;
|
|
358
|
+
}
|
|
357
359
|
}
|
|
358
360
|
const lp = live ? live.get(pane) : null;
|
|
359
361
|
// Dropped when tmux says the pane is gone or no longer running THIS agent (hard kill / crash /
|
|
@@ -463,6 +465,7 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
|
|
|
463
465
|
catch { /* fs.watch unsupported → push falls back to evaluation on each /states poll */ }
|
|
464
466
|
}
|
|
465
467
|
function stop() {
|
|
468
|
+
nativeTail.clear();
|
|
466
469
|
if (watcher) {
|
|
467
470
|
watcher.close();
|
|
468
471
|
watcher = null;
|
|
@@ -503,23 +506,76 @@ export function createClaudeEvents({ commands, push, codexApp = null, file = DEF
|
|
|
503
506
|
return 'claude';
|
|
504
507
|
return rec.agent === 'claude' ? 'claude' : null;
|
|
505
508
|
}
|
|
506
|
-
function paneKind(pane) {
|
|
509
|
+
function paneKind(pane, process) {
|
|
507
510
|
const rec = readStateFile(file)[pane];
|
|
508
|
-
if (!rec || rec.agent
|
|
511
|
+
if (!rec || (rec.agent !== undefined && rec.agent !== 'claude'))
|
|
512
|
+
return null;
|
|
513
|
+
if (process && rec.process && (process.pid !== rec.process.pid || process.startedAt !== rec.process.startedAt))
|
|
509
514
|
return null;
|
|
515
|
+
if (rec.src === 'end') {
|
|
516
|
+
if (typeof rec.payload.session_id === 'string')
|
|
517
|
+
nativeTail.release(rec.payload.session_id);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
const native = nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
|
|
521
|
+
if (native.status === 'unknown')
|
|
522
|
+
return null;
|
|
523
|
+
if (native.status === 'busy')
|
|
524
|
+
return 'working';
|
|
525
|
+
if (native.status === 'waiting')
|
|
526
|
+
return 'permission';
|
|
527
|
+
if (native.settled)
|
|
528
|
+
return 'idle';
|
|
529
|
+
}
|
|
530
|
+
// Activity is not the Inbox roster: neutral lifecycle edges deliberately create no Inbox card,
|
|
531
|
+
// but a completed manual /compact must release the conversation queue. Automatic compaction
|
|
532
|
+
// continues the current turn, so it must not admit another prompt between compaction and generation.
|
|
533
|
+
if (rec.src === 'compact') {
|
|
534
|
+
return rec.payload.trigger === 'manual' ? 'idle'
|
|
535
|
+
: rec.payload.trigger === 'auto' ? 'working' : null;
|
|
536
|
+
}
|
|
537
|
+
if (rec.src === 'start') {
|
|
538
|
+
// Native full/partial/reactive compaction calls SessionStart(compact) before PostCompact.
|
|
539
|
+
// It identifies neither an idle session nor specifically an automatic compaction.
|
|
540
|
+
return ['startup', 'clear', 'resume'].includes(String(rec.payload.source)) ? 'idle'
|
|
541
|
+
: rec.payload.source === 'compact' ? 'compacting' : null;
|
|
542
|
+
}
|
|
510
543
|
return classifyRecord(rec)?.kind ?? null;
|
|
511
544
|
}
|
|
512
|
-
function paneCompletionToken(pane) {
|
|
545
|
+
function paneCompletionToken(pane, process) {
|
|
513
546
|
const rec = readStateFile(file)[pane];
|
|
514
|
-
if (!rec || rec.agent
|
|
547
|
+
if (!rec || (rec.agent !== undefined && rec.agent !== 'claude'))
|
|
548
|
+
return null;
|
|
549
|
+
if (process && rec.process && (process.pid !== rec.process.pid || process.startedAt !== rec.process.startedAt))
|
|
550
|
+
return null;
|
|
551
|
+
const native = rec.src === 'end' ? undefined : nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
|
|
552
|
+
if (native?.status && native.status !== 'idle')
|
|
515
553
|
return null;
|
|
516
|
-
|
|
554
|
+
if (native?.settled)
|
|
555
|
+
return native.settled;
|
|
556
|
+
// An idle startup/resume/clear is the first dispatch baseline. Give it a stable token too:
|
|
557
|
+
// the queue can then detect a fast completion even when no poll observed its busy phase.
|
|
558
|
+
const baseline = rec.src === 'start' && ['startup', 'clear', 'resume'].includes(String(rec.payload.source));
|
|
559
|
+
const kind = (rec.src === 'compact' && rec.payload.trigger === 'manual') || baseline
|
|
560
|
+
? 'idle' : classifyRecord(rec)?.kind ?? null;
|
|
517
561
|
if (kind !== 'done' && kind !== 'error' && kind !== 'end' && kind !== 'idle')
|
|
518
562
|
return null;
|
|
519
|
-
|
|
563
|
+
const nativeCompletion = native?.localCommand;
|
|
564
|
+
if (nativeCompletion !== undefined)
|
|
565
|
+
return nativeCompletion;
|
|
566
|
+
return Number.isFinite(rec.ts) && rec.ts >= 0
|
|
567
|
+
? `claude-${baseline ? 'baseline' : 'completed'}:${rec.ts}${rec.sequence === undefined ? '' : `:${rec.sequence}`}`
|
|
568
|
+
: null;
|
|
520
569
|
}
|
|
521
570
|
return {
|
|
522
571
|
getStates, identifyPaneAgents, start, stop, paneSession, paneAgent,
|
|
523
|
-
paneKind, paneCompletionToken,
|
|
572
|
+
paneKind, paneCompletionToken, nativeTail,
|
|
573
|
+
paneRestoredPrompt(pane) {
|
|
574
|
+
const rec = readStateFile(file)[pane];
|
|
575
|
+
if (!rec || rec.src === 'end' || (rec.agent !== undefined && rec.agent !== 'claude'))
|
|
576
|
+
return null;
|
|
577
|
+
const native = nativeTail.read(rec.payload, rec.ts, now(), rec.process, rec.src);
|
|
578
|
+
return native.status === 'idle' ? native.restoredPrompt ?? null : null;
|
|
579
|
+
},
|
|
524
580
|
};
|
|
525
581
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { text, select, confirm, isCancel } from '@clack/prompts';
|
|
2
|
+
import { AuthControlError, connectAuthControl } from '../deviceAuth/control.js';
|
|
3
|
+
import { DeviceAuthError, parseExpire, validateName } from '../deviceAuth/service.js';
|
|
4
|
+
import { tokenWarning } from './authDefaults.js';
|
|
5
|
+
import { t } from './i18n/index.js';
|
|
6
|
+
const usage = (target) => target === 'device'
|
|
7
|
+
? 'Usage: handmux auth device status|on|off|add|list|edit|revoke'
|
|
8
|
+
: target === 'address'
|
|
9
|
+
? 'Usage: handmux auth address status|on|off|list|add|remove'
|
|
10
|
+
: 'Usage: handmux auth device <action> | handmux auth address <action>';
|
|
11
|
+
export function parseAuthArgs(argv, interactive) {
|
|
12
|
+
const [target, action, ...rest] = argv;
|
|
13
|
+
if (target !== 'device' && target !== 'address') {
|
|
14
|
+
throw new Error(usage());
|
|
15
|
+
}
|
|
16
|
+
const deviceActions = ['status', 'on', 'off', 'add', 'list', 'edit', 'revoke'];
|
|
17
|
+
const addressActions = ['status', 'on', 'off', 'list', 'add', 'remove'];
|
|
18
|
+
const allowed = target === 'device' ? deviceActions : addressActions;
|
|
19
|
+
if (!action || !allowed.includes(action))
|
|
20
|
+
throw new Error(usage(target));
|
|
21
|
+
const result = { target, action: action };
|
|
22
|
+
if (target === 'address') {
|
|
23
|
+
if (action === 'add' || action === 'remove') {
|
|
24
|
+
if (rest.length !== 1 || !rest[0])
|
|
25
|
+
throw new Error(`Usage: handmux auth address ${action} <origin>`);
|
|
26
|
+
result.origin = rest[0];
|
|
27
|
+
}
|
|
28
|
+
else if (rest.length)
|
|
29
|
+
throw new Error(`Usage: handmux auth address ${action}`);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
if (['status', 'on', 'off', 'list'].includes(action)) {
|
|
33
|
+
if (rest.length)
|
|
34
|
+
throw new Error(`Usage: handmux auth device ${action}`);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < rest.length; i++) {
|
|
38
|
+
const arg = rest[i];
|
|
39
|
+
if (arg === '--code' || arg === '--name' || arg === '--expire') {
|
|
40
|
+
const key = arg === '--code' ? 'code' : arg === '--name' ? 'name' : 'expire';
|
|
41
|
+
const value = rest[++i];
|
|
42
|
+
if (!value || value.startsWith('--') || result[key] !== undefined)
|
|
43
|
+
throw new Error(`Provide ${arg} exactly once with a value`);
|
|
44
|
+
result[key] = value;
|
|
45
|
+
}
|
|
46
|
+
else if (arg.startsWith('-') || result.id !== undefined)
|
|
47
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
48
|
+
else if (action === 'add')
|
|
49
|
+
throw new Error('Use --code for non-interactive add; interactive add reads the code after handmux auth device add');
|
|
50
|
+
else
|
|
51
|
+
result.id = arg;
|
|
52
|
+
}
|
|
53
|
+
if (action === 'list' && (result.id || result.code || result.name !== undefined || result.expire !== undefined))
|
|
54
|
+
throw new Error('Usage: handmux auth device list');
|
|
55
|
+
if (action !== 'add' && result.code !== undefined)
|
|
56
|
+
throw new Error('The --code flag is only valid for handmux auth device add');
|
|
57
|
+
if (action === 'revoke' && (result.name !== undefined || result.expire !== undefined))
|
|
58
|
+
throw new Error('Usage: handmux auth device revoke <device-id>');
|
|
59
|
+
if ((action === 'edit' || action === 'revoke') && !result.id)
|
|
60
|
+
throw new Error('Provide the exact device ID from handmux auth device list');
|
|
61
|
+
if (action === 'edit' && result.name === undefined && result.expire === undefined)
|
|
62
|
+
throw new Error('Provide --name or --expire');
|
|
63
|
+
if (result.name !== undefined)
|
|
64
|
+
result.name = validateName(result.name);
|
|
65
|
+
if (result.expire !== undefined)
|
|
66
|
+
parseExpire(result.expire);
|
|
67
|
+
if (action === 'add') {
|
|
68
|
+
if (result.code !== undefined && !/^\d{6}$/.test(result.code))
|
|
69
|
+
throw new Error('Code must be exactly 6 digits, preserving leading zeros');
|
|
70
|
+
if (!interactive && (result.code === undefined || result.name === undefined || result.expire === undefined))
|
|
71
|
+
throw new Error('Non-interactive add requires --code, --name and --expire; no code has been consumed');
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
const safeText = (value) => String(value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
76
|
+
const controlErrorCopy = {
|
|
77
|
+
AUTH_UNAVAILABLE: 'auth.error.unavailable', INVALID_COMMAND: 'auth.error.invalidCommand',
|
|
78
|
+
CODE_INVALID: 'auth.error.codeInvalid', CLAIM_RATE_LIMIT: 'auth.error.rateLimit',
|
|
79
|
+
PAIRING_NOT_FOUND: 'auth.error.pairingGone', PAIRING_INACTIVE: 'auth.error.pairingGone',
|
|
80
|
+
DEVICE_NOT_FOUND: 'auth.error.deviceNotFound', DEVICE_INACTIVE: 'auth.error.deviceInactive',
|
|
81
|
+
DEVICE_CONFLICT: 'auth.error.conflict', INVALID_EDIT: 'auth.error.invalidEdit',
|
|
82
|
+
INVALID_VERSION: 'auth.error.invalidVersion', PAIRING_CAPACITY: 'auth.error.pairingCapacity',
|
|
83
|
+
TOKEN_REQUIRED: 'auth.error.tokenRequired', DEVICE_REQUIRED: 'auth.error.deviceRequired',
|
|
84
|
+
DEVICE_EXPIRY_CLI_ONLY: 'auth.error.expiryCliOnly',
|
|
85
|
+
INVALID_EXPIRE: 'auth.invalidDuration', INVALID_NAME: 'auth.invalidName',
|
|
86
|
+
INVALID_ORIGIN: 'auth.error.invalidOrigin', ORIGIN_LIMIT: 'auth.error.originLimit',
|
|
87
|
+
SESSION_INVALID: 'auth.error.sessionInvalid', DEVICE_AUTH_DISABLED: 'auth.error.unavailable',
|
|
88
|
+
};
|
|
89
|
+
const iso = (value) => typeof value === 'number' ? new Date(value).toISOString() : 'never';
|
|
90
|
+
function outputDevice(value) {
|
|
91
|
+
const d = value;
|
|
92
|
+
return `${safeText(d.id)}\t${safeText(d.name)}\t${safeText(d.status)}\t${iso(d.expires_at)}\t${iso(d.authorized_at)}\t${iso(d.last_used_at)}\t${safeText(d.browser_summary)}${'previousExpiresAt' in d ? `\tprevious expiry: ${iso(d.previousExpiresAt)}` : ''}`;
|
|
93
|
+
}
|
|
94
|
+
const canceled = Symbol('canceled');
|
|
95
|
+
async function ask(promise) { const result = await promise; if (isCancel(result))
|
|
96
|
+
throw canceled; return result; }
|
|
97
|
+
export async function runAuthCommand({ argv, home, interactive = !!process.stdin.isTTY, log = console.log, err = console.error, connect = connectAuthControl }) {
|
|
98
|
+
let client;
|
|
99
|
+
try {
|
|
100
|
+
const args = parseAuthArgs(argv, interactive);
|
|
101
|
+
client = await connect(home);
|
|
102
|
+
if (args.target === 'address') {
|
|
103
|
+
if (args.action === 'status' || args.action === 'list') {
|
|
104
|
+
const result = await client.request({ op: 'address-status' });
|
|
105
|
+
log(`可信地址保护: ${result.enabled ? '已开启' : '未开启'}`);
|
|
106
|
+
if (!result.enabled)
|
|
107
|
+
log(tokenWarning(t('auth.addressWarning')));
|
|
108
|
+
result.origins.forEach(origin => log(origin));
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
if (args.action === 'on' || args.action === 'off') {
|
|
112
|
+
if (args.action === 'off' && interactive && !await ask(confirm({ message: t('auth.addressDisableConfirm'), initialValue: false })))
|
|
113
|
+
throw canceled;
|
|
114
|
+
const result = await client.request({ op: 'address-policy', enabled: args.action === 'on' });
|
|
115
|
+
log(`可信地址保护: ${result.enabled ? '已开启' : '未开启'}`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
const result = await client.request({ op: args.action === 'add' ? 'address-add' : 'address-remove', origin: args.origin });
|
|
119
|
+
log(`访问地址: ${args.action === 'add' ? '已添加' : '已删除'}`);
|
|
120
|
+
result.origins.forEach(origin => log(origin));
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (args.action === 'status') {
|
|
124
|
+
const result = await client.request({ op: 'device-status' });
|
|
125
|
+
log(`可信设备保护: ${result.enabled ? '已开启' : '未开启'}`);
|
|
126
|
+
if (!result.enabled)
|
|
127
|
+
log(tokenWarning(t('auth.warning')));
|
|
128
|
+
log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
|
|
129
|
+
result.devices.forEach(d => log(outputDevice(d)));
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
if (args.action === 'on' || args.action === 'off') {
|
|
133
|
+
if (args.action === 'off' && interactive && !await ask(confirm({ message: t('auth.deviceDisableConfirm'), initialValue: false })))
|
|
134
|
+
throw canceled;
|
|
135
|
+
const result = await client.request({ op: 'device-policy', enabled: args.action === 'on' });
|
|
136
|
+
log(`可信设备保护: ${result.enabled ? '已开启' : '未开启'}`);
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (args.action === 'list') {
|
|
140
|
+
const result = await client.request({ op: 'device-list' });
|
|
141
|
+
log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
|
|
142
|
+
result.forEach(d => log(outputDevice(d)));
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
if (args.action !== 'add') {
|
|
146
|
+
const result = await client.request({ op: `device-${args.action}`, id: args.id, name: args.name, expire: args.expire });
|
|
147
|
+
log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
|
|
148
|
+
log(outputDevice(result));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
log(t('auth.safety'));
|
|
152
|
+
if (!args.code)
|
|
153
|
+
args.code = await ask(text({ message: t('auth.code'), validate: v => /^\d{6}$/.test(v ?? '') ? undefined : t('auth.codeInvalid') }));
|
|
154
|
+
const claimed = await client.request({ op: 'claim', code: args.code });
|
|
155
|
+
const needsPrompt = args.name === undefined || args.expire === undefined;
|
|
156
|
+
log(t('auth.claimed'));
|
|
157
|
+
log(safeText(claimed.browserSummary));
|
|
158
|
+
if (claimed.origin)
|
|
159
|
+
log(`${t('auth.origin')}: ${safeText(claimed.origin)}`);
|
|
160
|
+
if (args.name === undefined) {
|
|
161
|
+
const fallback = safeText(claimed.browserSummary).slice(0, 80) || 'Browser';
|
|
162
|
+
args.name = await ask(text({ message: t('auth.name'), defaultValue: fallback, placeholder: fallback, validate: v => { try {
|
|
163
|
+
validateName(v || fallback);
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
return e instanceof DeviceAuthError && e.code === 'INVALID_NAME' ? t('auth.invalidName') : e.message;
|
|
167
|
+
} return undefined; } }));
|
|
168
|
+
args.name = args.name || fallback;
|
|
169
|
+
}
|
|
170
|
+
if (args.expire === undefined) {
|
|
171
|
+
const expire = await ask(select({ message: t('auth.expire'), initialValue: '30d', options: [
|
|
172
|
+
...['1h', '1d', '7d', '30d'].map(value => ({ value, label: value })),
|
|
173
|
+
{ value: 'custom', label: t('auth.custom') }, { value: 'never', label: t('auth.never') },
|
|
174
|
+
] }));
|
|
175
|
+
args.expire = expire === 'custom' ? await ask(text({ message: t('auth.duration'), validate: v => { try {
|
|
176
|
+
parseExpire(v);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
return e instanceof DeviceAuthError && e.code === 'INVALID_EXPIRE' ? t('auth.invalidDuration') : e.message;
|
|
180
|
+
} return undefined; } })) : expire;
|
|
181
|
+
}
|
|
182
|
+
if (needsPrompt && !await ask(confirm({ message: t('auth.confirm'), initialValue: true })))
|
|
183
|
+
throw canceled;
|
|
184
|
+
const result = await client.request({ op: 'authorize', id: claimed.id, name: args.name, expire: args.expire });
|
|
185
|
+
log('ID\tNAME\tSTATUS\tEXPIRES\tADDED\tLAST ACCESS\tBROWSER');
|
|
186
|
+
log(outputDevice(result));
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error === canceled)
|
|
191
|
+
err(t('auth.canceled'));
|
|
192
|
+
else if (error instanceof AuthControlError || error instanceof DeviceAuthError) {
|
|
193
|
+
err(t(controlErrorCopy[error.code] ?? 'auth.error.controlFailed'));
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
err(error instanceof Error ? safeText(error.message) : t('auth.error.controlFailed'));
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
client?.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { configPath, readState, readSupervisorConfig, statePath, supervisorConfigPath } from './state.js';
|
|
3
|
+
// Only the CLI has installation evidence. Pure config mappers must not guess whether {} is a
|
|
4
|
+
// newcomer or an existing setup. Authentication policy itself is persisted by the server database.
|
|
5
|
+
export function installationAuthDefaults(home, target = configPath(home), flags = {}, env = process.env) {
|
|
6
|
+
const state = readState(home);
|
|
7
|
+
const supervisor = readSupervisorConfig(home);
|
|
8
|
+
const hasConfig = fs.existsSync(target) || fs.existsSync(configPath(home));
|
|
9
|
+
const hasHistory = fs.existsSync(statePath(home)) || fs.existsSync(supervisorConfigPath(home));
|
|
10
|
+
const explicitToken = flags.token != null || env.HANDMUX_TOKEN != null;
|
|
11
|
+
const isNew = !hasConfig && !hasHistory && !explicitToken;
|
|
12
|
+
const token = [state?.token, supervisor?.token].find((value) => typeof value === 'string' && value.length > 0);
|
|
13
|
+
return { isNew, ...(typeof token === 'string' ? { token } : {}) };
|
|
14
|
+
}
|
|
15
|
+
// Keep this shared by access output: foreground, background, and already-running startup paths.
|
|
16
|
+
export function tokenWarning(message, color = !!process.stdout.isTTY) {
|
|
17
|
+
return color ? `\u001b[33m${message}\u001b[39m` : message;
|
|
18
|
+
}
|
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
|
-
import { writeJsonAtomic, deployHookScripts, removeHookScripts } from './hookScaffold.js';
|
|
11
|
+
import { writeJsonAtomic, deployHookScripts, removeHookScripts, shellWord } from './hookScaffold.js';
|
|
12
12
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
13
|
const settingsOf = (value) => isRecord(value) ? { ...value } : {};
|
|
14
14
|
const readSettings = (home) => {
|
|
@@ -135,7 +135,7 @@ function addHook(hooks, e, dest) {
|
|
|
135
135
|
dropOurHook(hooks, e.event);
|
|
136
136
|
const existing = hooks[e.event];
|
|
137
137
|
const groups = hooks[e.event] = Array.isArray(existing) ? [...existing] : [];
|
|
138
|
-
groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${dest} ${e.src}`, async: true, timeout: 5 }] });
|
|
138
|
+
groups.push({ matcher: e.matcher || '', hooks: [{ type: 'command', command: `${shellWord(dest)} ${e.src}`, async: true, timeout: 5 }] });
|
|
139
139
|
}
|
|
140
140
|
// Pure: return a NEW settings object with our six hooks merged into settings.hooks, idempotently, leaving
|
|
141
141
|
// the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
|
|
@@ -193,7 +193,7 @@ export function hooksStatus(home = homedir()) {
|
|
|
193
193
|
}
|
|
194
194
|
function hasExpectedHook(hooks, event, dest) {
|
|
195
195
|
const groups = hooks[event.event];
|
|
196
|
-
const command = `${dest} ${event.src}`;
|
|
196
|
+
const command = `${shellWord(dest)} ${event.src}`;
|
|
197
197
|
return Array.isArray(groups) && groups.some((group) => (isRecord(group)
|
|
198
198
|
&& group.matcher === (event.matcher || '')
|
|
199
199
|
&& Array.isArray(group.hooks)
|
|
@@ -3,6 +3,9 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
|
|
5
5
|
const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
|
|
6
|
+
// Env files are sourced by /bin/sh; settings commands use the same shell word syntax.
|
|
7
|
+
export const shellWord = (value) => /^[a-zA-Z0-9_./-]+$/.test(value)
|
|
8
|
+
? value : `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
6
9
|
// Atomic write (tmp + rename) so a crash can't leave a half-written config file. Text in, text out — callers
|
|
7
10
|
// pass raw TOML for config.toml, or use writeJsonAtomic for pretty-printed settings.json.
|
|
8
11
|
export function writeFileAtomic(file, text) {
|
|
@@ -24,7 +27,8 @@ export function deployHookScripts(hooksDir, srcDir, stateFile) {
|
|
|
24
27
|
}
|
|
25
28
|
fs.chmodSync(path.join(hooksDir, 'handmux-notify.sh'), 0o755);
|
|
26
29
|
fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), [
|
|
27
|
-
`HANDMUX_STATE=${stateFile}`,
|
|
30
|
+
`HANDMUX_STATE=${shellWord(stateFile)}`,
|
|
31
|
+
`HANDMUX_CLAUDE_EVENTS=${shellWord(`${stateFile}.events`)}`,
|
|
28
32
|
'',
|
|
29
33
|
].join('\n'), { mode: 0o600 });
|
|
30
34
|
}
|
package/dist/src/cli/i18n/en.js
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
// English catalog (the fallback locale). Keys are grouped by command/area. `{var}` placeholders are filled
|
|
2
2
|
// by translate(). Keep this in lockstep with zh.js — a missing zh key silently falls back to the line here.
|
|
3
3
|
export default {
|
|
4
|
+
'auth.section': 'Authentication & security',
|
|
5
|
+
'auth.trusted': 'Device protection',
|
|
6
|
+
'auth.token': 'Token login',
|
|
7
|
+
'auth.manageHint': 'Use the CLI for protection switches; view and manage devices and access addresses in Web settings.',
|
|
8
|
+
'auth.noDevices': 'No devices are authorized yet. Open Handmux in the target browser, then run handmux auth device add on this computer.',
|
|
9
|
+
'auth.access': 'Device authorization: open Handmux in the target browser, then run handmux auth device add here and follow the prompts.',
|
|
10
|
+
'auth.warning': '⚠ Trusted device protection is off: a Token alone is enough to access Handmux. We recommend running handmux auth device on to enable it.',
|
|
11
|
+
'auth.addressWarning': '⚠ Trusted address protection is off: any address can attempt to connect. We recommend running handmux auth address on to enable it.',
|
|
12
|
+
'auth.deviceDisableConfirm': '⚠ Turning off trusted device protection increases security risk: a Token alone will grant access. Confirm you want to turn it off?',
|
|
13
|
+
'auth.addressDisableConfirm': '⚠ Turning off trusted address protection increases security risk: any address can attempt to connect. Confirm you want to turn it off?',
|
|
14
|
+
'auth.code': 'Enter the 6-digit code shown in the target browser',
|
|
15
|
+
'auth.codeInvalid': 'Enter exactly 6 digits.',
|
|
16
|
+
'auth.claimed': 'The authorization request was found. Set a device name and duration within 5 minutes; the browser can sign in after you confirm.',
|
|
17
|
+
'auth.origin': 'Origin',
|
|
18
|
+
'auth.name': 'Device name',
|
|
19
|
+
'auth.expire': 'Authorization duration',
|
|
20
|
+
'auth.custom': 'Custom',
|
|
21
|
+
'auth.never': 'No expiry',
|
|
22
|
+
'auth.duration': 'Enter a duration such as 1h, 7d, 30d, or never',
|
|
23
|
+
'auth.confirm': 'Authorize this browser?',
|
|
24
|
+
'auth.canceled': 'This authorization was canceled.',
|
|
25
|
+
'auth.safety': 'Only confirm the browser you are adding. Never enter a code sent by someone else.',
|
|
26
|
+
'auth.invalidName': 'The device name must be 1–80 characters and contain no control characters.',
|
|
27
|
+
'auth.invalidDuration': 'Enter a duration such as 1h, 7d, 30d, or never.',
|
|
28
|
+
'auth.error.unavailable': 'Cannot reach the Handmux authorization service. Make sure Handmux is running, then try again.',
|
|
29
|
+
'auth.error.invalidCommand': 'That authorization operation is invalid. Try again.',
|
|
30
|
+
'auth.error.codeInvalid': 'The code is invalid, expired, or already used. Request a new code in the browser.',
|
|
31
|
+
'auth.error.rateLimit': 'Too many code attempts. Try again later.',
|
|
32
|
+
'auth.error.pairingGone': 'This authorization request no longer exists or has expired. Request a new code in the browser.',
|
|
33
|
+
'auth.error.deviceNotFound': 'Device not found. Run handmux auth device list to check the device ID.',
|
|
34
|
+
'auth.error.deviceInactive': 'This device has expired or been unbound. Authorize it again.',
|
|
35
|
+
'auth.error.conflict': 'The device was changed by another operation. List devices again, then retry.',
|
|
36
|
+
'auth.error.invalidEdit': 'Provide at least --name or --expire.',
|
|
37
|
+
'auth.error.invalidVersion': 'The device was updated. List devices again, then retry.',
|
|
38
|
+
'auth.error.pairingCapacity': 'There are too many pending authorization requests. Try again later.',
|
|
39
|
+
'auth.error.tokenRequired': 'Provide the Token before starting authorization.',
|
|
40
|
+
'auth.error.deviceRequired': 'Authorize this browser before enabling device protection.',
|
|
41
|
+
'auth.error.expiryCliOnly': 'Change device expiry with the handmux CLI on the server.',
|
|
42
|
+
'auth.error.invalidOrigin': 'Invalid access address. Provide a complete http(s) URL.',
|
|
43
|
+
'auth.error.originLimit': 'The trusted access-address limit has been reached. Remove one before adding another.',
|
|
44
|
+
'auth.error.sessionInvalid': 'This device authorization is no longer valid. Pair it again.',
|
|
45
|
+
'auth.error.controlFailed': 'The authorization operation did not complete. Check Handmux status, then try again.',
|
|
4
46
|
// generic
|
|
5
47
|
'err.generic': '✗ {msg}',
|
|
6
48
|
'err.configNotFound': '✗ --config {path}: not found',
|
|
@@ -108,7 +150,7 @@ export default {
|
|
|
108
150
|
'access.pending': '(pending…)',
|
|
109
151
|
'access.lan': ' 📶 lan {url}',
|
|
110
152
|
'access.local': ' 💻 local {url}',
|
|
111
|
-
'access.token': ' 🔑
|
|
153
|
+
'access.token': ' 🔑 Token login {token}',
|
|
112
154
|
'access.reachable': ' ✓ reachable',
|
|
113
155
|
'access.unreachable': ' ⚠ tunnel up but {url} did not answer — check the server-side reverse proxy / DNS',
|
|
114
156
|
'access.hint': ' handmux status | stop',
|
|
@@ -179,6 +221,7 @@ export default {
|
|
|
179
221
|
'setup.askSshHost': 'ssh host (user@host[:port])',
|
|
180
222
|
'setup.askRemotePort': 'remote port on the ssh host',
|
|
181
223
|
'setup.askPublicUrl': 'public url — http(s):// as appropriate (blank = http://host:remotePort)',
|
|
224
|
+
'setup.askDirectPublicUrl': 'custom access URL (your own reverse tunnel or proxy; blank = local/LAN direct only)',
|
|
182
225
|
'setup.natappGuide': 'Where to get the authtoken: register free at https://natapp.cn → create a tunnel → copy its authtoken (the free tier is enough to start).',
|
|
183
226
|
'setup.cpolarGuide': 'Where to get the authtoken: register free at https://cpolar.com → open the dashboard → Verify → copy your authtoken.',
|
|
184
227
|
'setup.askAuthtoken': 'authtoken',
|
|
@@ -194,19 +237,19 @@ export default {
|
|
|
194
237
|
'setup.secConnection': 'Connection',
|
|
195
238
|
'setup.secName': 'Name',
|
|
196
239
|
'setup.secPort': 'Port',
|
|
197
|
-
'setup.secToken': 'Token',
|
|
240
|
+
'setup.secToken': 'Token login',
|
|
198
241
|
'setup.secBrowser': 'Web preview',
|
|
199
242
|
'setup.browserOff': 'not configured · Direct mode only',
|
|
200
243
|
'setup.askBrowserDomain': 'Web preview proxy domain (blank = Direct mode only)',
|
|
201
244
|
'setup.browserAbout': 'Enter a proxy domain (for example, preview.example.com) and route its wildcard subdomains to Handmux over HTTPS; leave it blank for Direct mode only.',
|
|
202
|
-
'setup.tokenAuto': '
|
|
203
|
-
'setup.tokenCustom': '
|
|
204
|
-
'setup.tokenRandom': 'Generate a random
|
|
205
|
-
'setup.tokenReset': '
|
|
206
|
-
'setup.askToken': '
|
|
207
|
-
'setup.tokenGenerated': 'New
|
|
208
|
-
'setup.valToken': 'enter
|
|
209
|
-
'setup.valTokenSpace': 'no spaces — the
|
|
245
|
+
'setup.tokenAuto': 'saved (the Token does not rotate automatically)',
|
|
246
|
+
'setup.tokenCustom': 'Change the Token…',
|
|
247
|
+
'setup.tokenRandom': 'Generate and save a new random Token',
|
|
248
|
+
'setup.tokenReset': 'The Token does not rotate automatically',
|
|
249
|
+
'setup.askToken': 'Token — required together with a trusted device',
|
|
250
|
+
'setup.tokenGenerated': 'New Token: {token}',
|
|
251
|
+
'setup.valToken': 'enter the Token',
|
|
252
|
+
'setup.valTokenSpace': 'no spaces — enter the Token separately on the login page',
|
|
210
253
|
'setup.secLanguage': 'CLI language / 命令行语言',
|
|
211
254
|
'setup.secPush': 'Push',
|
|
212
255
|
'setup.secVoice': 'Voice',
|
|
@@ -236,6 +279,7 @@ export default {
|
|
|
236
279
|
'setup.valRequired': '{label} is required',
|
|
237
280
|
'setup.valHost': 'enter a valid domain (e.g. myapp.example.com)',
|
|
238
281
|
'setup.valPreviewDomain': 'enter only a domain, e.g. preview.example.com; do not include http://, https://, a port, or *.',
|
|
282
|
+
'setup.valPublicUrl': 'enter a complete http(s) origin such as https://handmux.example.com (no path or credentials)',
|
|
239
283
|
'setup.valContact': 'use mailto:you@example.com or https://your.site (a real domain — Apple rejects fake ones)',
|
|
240
284
|
'setup.sumTemp': 'temporary',
|
|
241
285
|
'setup.sumFixed': 'fixed',
|
|
@@ -248,6 +292,7 @@ export default {
|
|
|
248
292
|
'setup.connSshHost': 'SSH host',
|
|
249
293
|
'setup.connRemotePort': 'Remote port',
|
|
250
294
|
'setup.connPublicUrl': 'Public URL',
|
|
295
|
+
'setup.connDirectAuto': '(blank = local / LAN direct)',
|
|
251
296
|
'setup.connJump': 'Jump host',
|
|
252
297
|
'setup.connDomain': 'Domain',
|
|
253
298
|
'setup.connRegion': 'Region',
|
|
@@ -357,6 +402,16 @@ export default {
|
|
|
357
402
|
handmux stop | restart | status
|
|
358
403
|
handmux logs [--follow] [--lines N]
|
|
359
404
|
handmux push <title> <body> notify your phone from a script (--session X · --device K · --tag T · --url U)
|
|
405
|
+
handmux auth device status show device protection status and devices
|
|
406
|
+
handmux auth device on|off turn device protection on or off
|
|
407
|
+
handmux auth device add interactively authorize a browser (automation uses --code, --name and --expire)
|
|
408
|
+
handmux auth device list list full device IDs, names, expiry and access times
|
|
409
|
+
handmux auth device edit <id> update the device name or expiry
|
|
410
|
+
handmux auth device revoke <id> revoke a device immediately
|
|
411
|
+
handmux auth address status show trusted address protection status and addresses
|
|
412
|
+
handmux auth address on|off turn trusted address protection on or off
|
|
413
|
+
handmux auth address add <origin> add an access address
|
|
414
|
+
handmux auth address remove <origin> remove an access address
|
|
360
415
|
handmux codex [args...] launch a Codex TUI synchronized with chat view
|
|
361
416
|
handmux pi [args...] launch Pi with its arguments unchanged
|
|
362
417
|
handmux agent [list] show supported Agent integrations
|
|
@@ -384,7 +439,7 @@ start flags (matching env var in parens):
|
|
|
384
439
|
--tunnel none|cloudflare|cloudflare-named|ssh|natapp|cpolar expose method (default: none)
|
|
385
440
|
--port N server port (HANDMUX_PORT, default: 19999)
|
|
386
441
|
--host H bind host (HANDMUX_HOST, default: 0.0.0.0)
|
|
387
|
-
--token S auth token (HANDMUX_TOKEN, default:
|
|
442
|
+
--token S auth token (HANDMUX_TOKEN, default: reuse existing or generate)
|
|
388
443
|
--name "My Box" app name in the browser tab + home-screen icon (HANDMUX_APP_NAME)
|
|
389
444
|
--public-url URL public url to advertise (HANDMUX_PUBLIC_URL; any tunnel, incl. none if
|
|
390
445
|
you run your own; ssh defaults to http://host:remotePort; for
|