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
|
@@ -460,13 +460,22 @@ function turnPrompt(turn) {
|
|
|
460
460
|
}
|
|
461
461
|
return '';
|
|
462
462
|
}
|
|
463
|
+
function currentTurnId(state, remembered = state?.activeTurnId) {
|
|
464
|
+
// Native history can retain unfinished internal turns after the thread is idle (for example /review).
|
|
465
|
+
// Historical turn records must never override the authoritative current thread lifecycle.
|
|
466
|
+
if ((state?.status || state?.thread?.status)?.type === 'idle')
|
|
467
|
+
return null;
|
|
468
|
+
return remembered || [...(state?.thread?.turns || [])].reverse()
|
|
469
|
+
.find((turn) => turn.status === 'inProgress')?.id || null;
|
|
470
|
+
}
|
|
463
471
|
function activeTurnPrompt(state) {
|
|
472
|
+
if ((state?.status || state?.thread?.status)?.type === 'idle')
|
|
473
|
+
return '';
|
|
474
|
+
const turnId = currentTurnId(state);
|
|
464
475
|
if (state?.activePrompt)
|
|
465
476
|
return state.activePrompt;
|
|
466
477
|
const turns = Array.isArray(state?.thread?.turns) ? state.thread.turns : [];
|
|
467
|
-
const active =
|
|
468
|
-
? turns.find((turn) => turn.id === state.activeTurnId)
|
|
469
|
-
: [...turns].reverse().find((turn) => turn.status === 'inProgress');
|
|
478
|
+
const active = turns.find((turn) => turn.id === turnId);
|
|
470
479
|
return turnPrompt(active);
|
|
471
480
|
}
|
|
472
481
|
function activeKind(status) {
|
|
@@ -867,6 +876,10 @@ class CodexAppConnection {
|
|
|
867
876
|
else if (message.method === 'thread/status/changed') {
|
|
868
877
|
const state = this.state(params.threadId);
|
|
869
878
|
state.status = params.status ?? null;
|
|
879
|
+
if (state.status?.type === 'idle') {
|
|
880
|
+
state.activeTurnId = null;
|
|
881
|
+
state.activePrompt = '';
|
|
882
|
+
}
|
|
870
883
|
const kind = activeKind(params.status);
|
|
871
884
|
if (!this.isCurrentThread(params.threadId)) {
|
|
872
885
|
/* retain the state for that thread, but never let a late background event rebind this pane */
|
|
@@ -931,11 +944,10 @@ class CodexAppConnection {
|
|
|
931
944
|
// Native Goal notifications do not consistently carry turnId. Bind every live lifecycle transition
|
|
932
945
|
// to the turn that is currently producing it so an active Goal set before the first item event cannot
|
|
933
946
|
// later mistake that same turn for its next durable injection opportunity.
|
|
934
|
-
const state = this.state(params.threadId);
|
|
935
947
|
const notifiedGoal = parseCodexGoal(params.goal);
|
|
936
948
|
const pendingTurnId = this.pendingGoalTurn(params.threadId);
|
|
937
949
|
const turnId = params.turnId
|
|
938
|
-
|| (pendingTurnId !== undefined ? pendingTurnId :
|
|
950
|
+
|| (pendingTurnId !== undefined ? pendingTurnId : this.activeTurn(params.threadId))
|
|
939
951
|
|| null;
|
|
940
952
|
this.applyGoalSnapshot(params.threadId, notifiedGoal, turnId, { emitClear: true });
|
|
941
953
|
}
|
|
@@ -1349,9 +1361,7 @@ class CodexAppConnection {
|
|
|
1349
1361
|
}
|
|
1350
1362
|
activeTurn(threadId) {
|
|
1351
1363
|
const state = this.state(threadId);
|
|
1352
|
-
return state
|
|
1353
|
-
|| [...(state.thread?.turns || [])].reverse().find((turn) => turn.status === 'inProgress')?.id
|
|
1354
|
-
|| null;
|
|
1364
|
+
return currentTurnId(state);
|
|
1355
1365
|
}
|
|
1356
1366
|
beginGoalMutation(threadId) {
|
|
1357
1367
|
const mutation = { token: Symbol('goal-mutation'), turnId: this.activeTurn(threadId) };
|
|
@@ -2356,8 +2366,7 @@ class CodexAppConnection {
|
|
|
2356
2366
|
state.status = resumedThread?.status || state.status;
|
|
2357
2367
|
const previousActiveTurnId = state.activeTurnId;
|
|
2358
2368
|
const previousActivePrompt = state.activePrompt;
|
|
2359
|
-
state.activeTurnId =
|
|
2360
|
-
.find((turn) => turn.status === 'inProgress')?.id || null;
|
|
2369
|
+
state.activeTurnId = currentTurnId(state, null);
|
|
2361
2370
|
const resumedPrompt = state.activeTurnId
|
|
2362
2371
|
? turnPrompt((resumedThread?.turns || []).find((turn) => turn.id === state.activeTurnId))
|
|
2363
2372
|
: '';
|
|
@@ -2439,7 +2448,8 @@ class CodexAppConnection {
|
|
|
2439
2448
|
// base snapshot still applies, and the cache now represents that current combined projection.
|
|
2440
2449
|
state.readRevision = state.revision;
|
|
2441
2450
|
state.status = fresh?.status || state.status;
|
|
2442
|
-
const
|
|
2451
|
+
const activeId = currentTurnId(state, null);
|
|
2452
|
+
const active = state.thread?.turns?.find((turn) => turn.id === activeId);
|
|
2443
2453
|
const previousActiveTurnId = state.activeTurnId;
|
|
2444
2454
|
const previousActivePrompt = state.activePrompt;
|
|
2445
2455
|
state.activeTurnId = active?.id || null;
|
|
@@ -3418,12 +3428,12 @@ export function createCodexAppServer({ home, connect = connectUnixWebSocket, exi
|
|
|
3418
3428
|
if (!client)
|
|
3419
3429
|
throw new Error('Codex session is not managed by Handmux');
|
|
3420
3430
|
await client.assertCurrentThread(threadId);
|
|
3421
|
-
|
|
3422
|
-
let turnId =
|
|
3431
|
+
await client.ensureThread(threadId);
|
|
3432
|
+
let turnId = client.activeTurn(threadId);
|
|
3423
3433
|
if (!turnId) {
|
|
3424
|
-
|
|
3434
|
+
await client.readThread(threadId);
|
|
3425
3435
|
client.requireCurrentThread(threadId);
|
|
3426
|
-
turnId =
|
|
3436
|
+
turnId = client.activeTurn(threadId);
|
|
3427
3437
|
}
|
|
3428
3438
|
if (!turnId)
|
|
3429
3439
|
return { interrupted: false };
|
|
@@ -376,6 +376,36 @@ function applyPatchCalls(patch) {
|
|
|
376
376
|
};
|
|
377
377
|
});
|
|
378
378
|
}
|
|
379
|
+
const isImageViewTool = (name) => name === 'view_image' || name === 'functions.view_image';
|
|
380
|
+
// Only literal path/cwd fields are evidence of a file location. The generic source parser deliberately
|
|
381
|
+
// keeps unknown JavaScript expressions as strings, which must never become download paths.
|
|
382
|
+
function imageToolInput(script, raw, before) {
|
|
383
|
+
const input = asRecord(parseToolInput(script, raw, before));
|
|
384
|
+
delete input.path;
|
|
385
|
+
delete input.cwd;
|
|
386
|
+
const value = raw.trim();
|
|
387
|
+
if (!value.startsWith('{') || !value.endsWith('}'))
|
|
388
|
+
return input;
|
|
389
|
+
for (const field of splitTopLevel(value.slice(1, -1), ',')) {
|
|
390
|
+
if (/^(\.\.\.|\[|get\s|set\s)/.test(field.trim())) {
|
|
391
|
+
delete input.path;
|
|
392
|
+
delete input.cwd;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const pieces = splitTopLevel(field, ':');
|
|
396
|
+
const key = pieces.shift()?.trim() || '';
|
|
397
|
+
const name = decodeJsString(key) ?? key;
|
|
398
|
+
if (name !== 'path' && name !== 'cwd')
|
|
399
|
+
continue;
|
|
400
|
+
const expression = pieces.join(':').trim();
|
|
401
|
+
const literal = stringTokenAt(expression, 0) === expression ? decodeJsString(expression) : null;
|
|
402
|
+
if (literal !== null)
|
|
403
|
+
input[name] = literal;
|
|
404
|
+
else
|
|
405
|
+
delete input[name];
|
|
406
|
+
}
|
|
407
|
+
return input;
|
|
408
|
+
}
|
|
379
409
|
function extractCustomCalls(script) {
|
|
380
410
|
const calls = [];
|
|
381
411
|
const re = /tools\.([A-Za-z][\w]*)\s*\(/g;
|
|
@@ -394,7 +424,8 @@ function extractCustomCalls(script) {
|
|
|
394
424
|
: applyPatchCalls(decoded)));
|
|
395
425
|
}
|
|
396
426
|
else {
|
|
397
|
-
calls.push({ name, input:
|
|
427
|
+
calls.push({ name, input: isImageViewTool(name)
|
|
428
|
+
? imageToolInput(script, arg.text, match.index) : parseToolInput(script, arg.text, match.index) });
|
|
398
429
|
}
|
|
399
430
|
re.lastIndex = arg.end;
|
|
400
431
|
}
|
|
@@ -595,6 +626,14 @@ export function createCodexTranscriptParser() {
|
|
|
595
626
|
const ts = typeof row.timestamp === 'string' ? row.timestamp : undefined;
|
|
596
627
|
if (row.type === 'event_msg') {
|
|
597
628
|
const payload = asRecord(row.payload);
|
|
629
|
+
const payloadError = asRecord(payload.error);
|
|
630
|
+
if (payload.type === 'task_complete' && typeof payloadError.message === 'string'
|
|
631
|
+
&& payloadError.message.trim()) {
|
|
632
|
+
messages.push({
|
|
633
|
+
i, type: 'notice', noticeLevel: 'warning', text: payloadError.message.trim(), ts,
|
|
634
|
+
...(typeof payload.turn_id === 'string' && payload.turn_id ? { turnId: payload.turn_id } : {}),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
598
637
|
adjacentUserClientId = payload.type === 'user_message'
|
|
599
638
|
&& typeof payload.client_id === 'string' && payload.client_id
|
|
600
639
|
? payload.client_id : undefined;
|
|
@@ -45,7 +45,7 @@ function normalize(record) {
|
|
|
45
45
|
const rawLimits = isRecord(payload.rate_limits) ? payload.rate_limits : {};
|
|
46
46
|
// A rollout may carry a model-specific limit (for example GPT-5.3-Codex-Spark's
|
|
47
47
|
// `codex_bengalfox`) instead of the account's main Codex limit. Keep its token/context data, but never
|
|
48
|
-
// project that independent percentage as the generic Codex quota shown by
|
|
48
|
+
// project that independent percentage as the generic Codex quota shown by handmux.
|
|
49
49
|
const limitId = typeof rawLimits.limit_id === 'string' ? rawLimits.limit_id : null;
|
|
50
50
|
const limits = limitId === null || limitId === 'codex' ? rawLimits : {};
|
|
51
51
|
return {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { networkInterfaces } from 'node:os';
|
|
2
|
+
import { originPatternMatches } from './deviceAuth/service.js';
|
|
3
|
+
import { readSessionSecret } from './deviceAuth/service.js';
|
|
4
|
+
import { setSessionCookie } from './deviceAuth/http.js';
|
|
5
|
+
import { withRequestAuthority } from './requestAuthority.js';
|
|
6
|
+
import { bearerFrom } from './auth.js';
|
|
7
|
+
import { requestOrigin } from './requestOrigin.js';
|
|
8
|
+
/** Host and forwarded headers select a known entry point; they never create a trusted origin. */
|
|
9
|
+
export function createAuthOriginResolver({ port, host, publicUrl, previewDomain, trustedOrigins = () => [], runtimePublicUrl = () => null, trustedOriginEnabled = () => true, }) {
|
|
10
|
+
const local = new Set();
|
|
11
|
+
const add = (hostname) => {
|
|
12
|
+
if (!hostname || hostname === '0.0.0.0' || hostname === '::')
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
local.add(new URL(`http://${hostname.includes(':') ? `[${hostname}]` : hostname}:${port}`).origin);
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
};
|
|
19
|
+
for (const hostname of ['localhost', '127.0.0.1', '::1', host])
|
|
20
|
+
add(hostname);
|
|
21
|
+
const configuredPatterns = [];
|
|
22
|
+
if (previewDomain) {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(/^https?:\/\//i.test(previewDomain) ? previewDomain : `https://${previewDomain}`);
|
|
25
|
+
if (url.hostname && !url.hostname.includes('*'))
|
|
26
|
+
configuredPatterns.push(`https://*.${url.hostname}`);
|
|
27
|
+
}
|
|
28
|
+
catch { /* an invalid preview domain is rejected by its own setup validation */ }
|
|
29
|
+
}
|
|
30
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
31
|
+
for (const entry of entries ?? [])
|
|
32
|
+
if (!entry.address.includes('%'))
|
|
33
|
+
add(entry.address);
|
|
34
|
+
}
|
|
35
|
+
return (req) => {
|
|
36
|
+
const origin = requestOrigin(req);
|
|
37
|
+
if (!origin)
|
|
38
|
+
return null;
|
|
39
|
+
// Origin restriction is an independent policy. Even when disabled, return the
|
|
40
|
+
// syntactically valid request origin so authentication can proceed with Token
|
|
41
|
+
// (and, when enabled, a trusted-device cookie).
|
|
42
|
+
if (!trustedOriginEnabled())
|
|
43
|
+
return origin;
|
|
44
|
+
if (local.has(origin))
|
|
45
|
+
return origin;
|
|
46
|
+
for (const known of [publicUrl, runtimePublicUrl()]) {
|
|
47
|
+
if (!known)
|
|
48
|
+
continue;
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(known);
|
|
51
|
+
if (!url.username && !url.password && ['http:', 'https:'].includes(url.protocol) && url.origin === origin)
|
|
52
|
+
return origin;
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
}
|
|
56
|
+
for (const pattern of [...configuredPatterns, ...trustedOrigins()]) {
|
|
57
|
+
if (!pattern)
|
|
58
|
+
continue;
|
|
59
|
+
if (originPatternMatches(pattern, origin))
|
|
60
|
+
return origin;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export function createDeviceAccess({ service, resolveOrigin }) {
|
|
66
|
+
const responses = new Map();
|
|
67
|
+
const stop = service.onRevoke((deviceId) => {
|
|
68
|
+
for (const res of responses.get(deviceId) ?? [])
|
|
69
|
+
res.destroy();
|
|
70
|
+
responses.delete(deviceId);
|
|
71
|
+
});
|
|
72
|
+
const authenticate = (req) => {
|
|
73
|
+
const origin = resolveOrigin(req);
|
|
74
|
+
if (!origin)
|
|
75
|
+
return null;
|
|
76
|
+
const device = service.authenticateRequest(req, origin);
|
|
77
|
+
const token = service.authenticateToken?.(bearerFrom(typeof req.headers.authorization === 'string' ? req.headers.authorization : undefined)
|
|
78
|
+
?? req.headers['x-handmux-token'], origin) ?? null;
|
|
79
|
+
if (!token)
|
|
80
|
+
return null;
|
|
81
|
+
return service.trustedDeviceEnabled === false ? token : device && token ? device : null;
|
|
82
|
+
};
|
|
83
|
+
const middleware = (req, res, next) => {
|
|
84
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
85
|
+
if (service.available === false) {
|
|
86
|
+
res.status(503).json({ error: 'authentication service unavailable', code: 'auth_unavailable' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const origin = resolveOrigin(req);
|
|
90
|
+
if (!origin || (req.headers.origin !== undefined && req.headers.origin !== origin)) {
|
|
91
|
+
res.status(403).json({ error: 'untrusted request origin', code: 'origin_rejected' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
let principal;
|
|
95
|
+
try {
|
|
96
|
+
principal = authenticate(req);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
res.status(503).json({ error: 'authentication storage unavailable', code: 'auth_unavailable' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!principal) {
|
|
103
|
+
res.status(401).json({ error: 'unauthorized', code: 'auth_required' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
res.locals.deviceAuth = principal;
|
|
107
|
+
let accepted = false;
|
|
108
|
+
const assertActive = () => {
|
|
109
|
+
if (!accepted && !service.isActive(principal)) {
|
|
110
|
+
throw Object.assign(new Error('Device authorization is no longer active'), { status: 401, code: 'auth_required' });
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
// Accepted Agent/background jobs may continue after logout; not-yet-issued HTTP work may not.
|
|
114
|
+
// Destroy/abort never marks a request accepted.
|
|
115
|
+
res.once('finish', () => { if (res.statusCode >= 200 && res.statusCode < 300)
|
|
116
|
+
accepted = true; });
|
|
117
|
+
res.locals.assertDeviceActive = assertActive;
|
|
118
|
+
const secret = readSessionSecret(req, origin);
|
|
119
|
+
if (secret && !principal.deviceId.startsWith('token_'))
|
|
120
|
+
setSessionCookie(res, origin, secret, principal.expiresAt);
|
|
121
|
+
const group = responses.get(principal.deviceId) ?? new Set();
|
|
122
|
+
group.add(res);
|
|
123
|
+
responses.set(principal.deviceId, group);
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
group.delete(res);
|
|
126
|
+
if (group.size === 0)
|
|
127
|
+
responses.delete(principal.deviceId);
|
|
128
|
+
};
|
|
129
|
+
res.once('finish', cleanup);
|
|
130
|
+
res.once('close', cleanup);
|
|
131
|
+
withRequestAuthority(assertActive, next);
|
|
132
|
+
};
|
|
133
|
+
return { middleware, authenticate, close: stop };
|
|
134
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { DeviceAuthError } from './service.js';
|
|
6
|
+
export const authSocketPath = (home) => path.join(home, '.handmux', 'auth.sock');
|
|
7
|
+
export class AuthControlError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = 'AuthControlError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function startDeviceAuthControl({ service, home, handlePush, handleShortcuts }) {
|
|
16
|
+
const socketPath = authSocketPath(home);
|
|
17
|
+
await fs.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 });
|
|
18
|
+
await fs.chmod(path.dirname(socketPath), 0o700);
|
|
19
|
+
try {
|
|
20
|
+
const existing = await fs.lstat(socketPath);
|
|
21
|
+
if (!existing.isSocket())
|
|
22
|
+
throw new Error('Auth control path is not a socket; refusing to replace it');
|
|
23
|
+
// The shared DB single-instance lock must already be acquired by the caller.
|
|
24
|
+
await fs.unlink(socketPath);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'))
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const sockets = new Set();
|
|
31
|
+
const server = net.createServer(socket => {
|
|
32
|
+
const owner = randomUUID();
|
|
33
|
+
let buffer = '';
|
|
34
|
+
let chain = Promise.resolve();
|
|
35
|
+
sockets.add(socket);
|
|
36
|
+
socket.setTimeout(360_000, () => socket.destroy());
|
|
37
|
+
socket.on('close', () => { sockets.delete(socket); service.cancelOwner(owner); });
|
|
38
|
+
socket.on('error', () => { });
|
|
39
|
+
socket.on('data', bytes => {
|
|
40
|
+
buffer += bytes.toString('utf8');
|
|
41
|
+
if (Buffer.byteLength(buffer) > 16_384) {
|
|
42
|
+
socket.destroy();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
let newline;
|
|
46
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
47
|
+
const line = buffer.slice(0, newline);
|
|
48
|
+
buffer = buffer.slice(newline + 1);
|
|
49
|
+
chain = chain.then(async () => {
|
|
50
|
+
if (socket.destroyed)
|
|
51
|
+
return;
|
|
52
|
+
try {
|
|
53
|
+
const input = JSON.parse(line);
|
|
54
|
+
if (!input || typeof input !== 'object')
|
|
55
|
+
throw new DeviceAuthError('INVALID_COMMAND', 'Invalid control command');
|
|
56
|
+
const args = input;
|
|
57
|
+
let result;
|
|
58
|
+
if (args.op === 'claim')
|
|
59
|
+
result = service.claim(args.code, owner);
|
|
60
|
+
else if (args.op === 'authorize')
|
|
61
|
+
result = service.authorize(String(args.id ?? ''), owner, { name: args.name, expire: args.expire });
|
|
62
|
+
else if (args.op === 'cancel') {
|
|
63
|
+
service.cancelOwner(owner);
|
|
64
|
+
result = { ok: true };
|
|
65
|
+
}
|
|
66
|
+
else if (args.op === 'device-status')
|
|
67
|
+
result = { enabled: service.trustedDeviceEnabled, devices: service.list() };
|
|
68
|
+
else if (args.op === 'device-policy') {
|
|
69
|
+
service.setTrustedDeviceEnabled(args.enabled === true);
|
|
70
|
+
result = { enabled: service.trustedDeviceEnabled };
|
|
71
|
+
}
|
|
72
|
+
else if (args.op === 'device-list')
|
|
73
|
+
result = service.list();
|
|
74
|
+
else if (args.op === 'device-edit')
|
|
75
|
+
result = service.edit(String(args.id ?? ''), { name: args.name, expire: args.expire });
|
|
76
|
+
else if (args.op === 'device-revoke')
|
|
77
|
+
result = service.revoke(String(args.id ?? ''));
|
|
78
|
+
else if (args.op === 'address-status')
|
|
79
|
+
result = { enabled: service.trustedOriginEnabled, origins: service.trustedOrigins };
|
|
80
|
+
else if (args.op === 'address-policy') {
|
|
81
|
+
service.setTrustedOriginEnabled(args.enabled === true);
|
|
82
|
+
result = { enabled: service.trustedOriginEnabled };
|
|
83
|
+
}
|
|
84
|
+
else if (args.op === 'address-add')
|
|
85
|
+
result = { origins: service.addTrustedOrigin(args.origin) };
|
|
86
|
+
else if (args.op === 'address-remove')
|
|
87
|
+
result = { origins: service.removeTrustedOrigin(args.origin) };
|
|
88
|
+
else if (args.op === 'push' && handlePush)
|
|
89
|
+
result = await handlePush(args.body);
|
|
90
|
+
else if (args.op === 'shortcuts' && handleShortcuts)
|
|
91
|
+
result = await handleShortcuts(args.body);
|
|
92
|
+
else
|
|
93
|
+
throw new DeviceAuthError('INVALID_COMMAND', 'Unknown control command');
|
|
94
|
+
if (!socket.destroyed)
|
|
95
|
+
socket.write(`${JSON.stringify({ ok: true, result })}\n`);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!socket.destroyed)
|
|
99
|
+
socket.write(`${JSON.stringify({ ok: false, error: error instanceof DeviceAuthError ? error.code : 'AUTH_UNAVAILABLE', message: error instanceof DeviceAuthError ? error.message : 'Authentication operation unavailable; retry or restart handmux' })}\n`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, () => { server.off('error', reject); resolve(); }); });
|
|
106
|
+
await fs.chmod(socketPath, 0o600);
|
|
107
|
+
return { close: async () => { for (const socket of sockets)
|
|
108
|
+
socket.destroy(); await new Promise(resolve => server.close(() => resolve())); await fs.unlink(socketPath).catch(() => { }); } };
|
|
109
|
+
}
|
|
110
|
+
export async function connectAuthControl(home) {
|
|
111
|
+
const socket = net.createConnection(authSocketPath(home));
|
|
112
|
+
let buffer = '';
|
|
113
|
+
let terminalError;
|
|
114
|
+
let pending;
|
|
115
|
+
const fail = (error) => { terminalError = error; pending?.reject(error); pending = undefined; };
|
|
116
|
+
socket.on('error', fail);
|
|
117
|
+
socket.on('close', () => fail(new Error('handmux control connection closed; start handmux and retry')));
|
|
118
|
+
socket.setTimeout(360_000, () => socket.destroy(new Error('Device setup timed out; pair again')));
|
|
119
|
+
socket.on('data', bytes => {
|
|
120
|
+
buffer += bytes.toString('utf8');
|
|
121
|
+
if (buffer.length > 4_000_000) {
|
|
122
|
+
socket.destroy(new Error('Control response exceeds limit'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const newline = buffer.indexOf('\n');
|
|
126
|
+
if (newline < 0)
|
|
127
|
+
return;
|
|
128
|
+
const line = buffer.slice(0, newline);
|
|
129
|
+
buffer = buffer.slice(newline + 1);
|
|
130
|
+
try {
|
|
131
|
+
const out = JSON.parse(line);
|
|
132
|
+
const p = pending;
|
|
133
|
+
pending = undefined;
|
|
134
|
+
if (out.ok)
|
|
135
|
+
p?.resolve(out.result);
|
|
136
|
+
else
|
|
137
|
+
p?.reject(new AuthControlError(out.error ?? 'AUTH_UNAVAILABLE', out.message ?? 'Authentication command unavailable; retry or restart handmux'));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
fail(new Error('Invalid handmux control response'));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); });
|
|
144
|
+
return {
|
|
145
|
+
request(args) {
|
|
146
|
+
if (terminalError || socket.destroyed || !socket.writable)
|
|
147
|
+
return Promise.reject(terminalError ?? new Error('handmux control connection closed; start handmux and pair again'));
|
|
148
|
+
if (pending)
|
|
149
|
+
return Promise.reject(new Error('Control command already pending'));
|
|
150
|
+
return new Promise((resolve, reject) => { pending = { resolve, reject }; socket.write(`${JSON.stringify(args)}\n`); });
|
|
151
|
+
},
|
|
152
|
+
close() { socket.destroy(); },
|
|
153
|
+
};
|
|
154
|
+
}
|