handmux 0.23.0 → 0.25.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/README.md +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Persistent machine-wide Codex usage snapshot utilities. Kept as CommonJS so the ESM usage service can
|
|
2
|
+
// load the same bounded rollout reader without adding a second package boundary.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const CHUNK_BYTES = 64 * 1024;
|
|
6
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
7
|
+
const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
8
|
+
const nullableNumber = (value) => value === null || finiteNumber(value);
|
|
9
|
+
const numberOrNull = (value) => finiteNumber(value) ? value : null;
|
|
10
|
+
function ensurePrivateDirectory(directory) {
|
|
11
|
+
const resolved = path.resolve(directory);
|
|
12
|
+
const parsed = path.parse(resolved);
|
|
13
|
+
const parts = resolved.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
|
14
|
+
const privateRoot = parts.indexOf('.handmux');
|
|
15
|
+
if (privateRoot < 0) {
|
|
16
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
for (let i = privateRoot; i < parts.length; i++) {
|
|
20
|
+
const current = path.join(parsed.root, ...parts.slice(0, i + 1));
|
|
21
|
+
fs.mkdirSync(current, { recursive: true, mode: 0o700 });
|
|
22
|
+
fs.chmodSync(current, 0o700);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function windowUsage(value) {
|
|
26
|
+
if (!isRecord(value) || !finiteNumber(value.used_percent))
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
usedPercent: value.used_percent,
|
|
30
|
+
windowMinutes: numberOrNull(value.window_minutes),
|
|
31
|
+
resetsAt: numberOrNull(value.resets_at),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function normalize(record) {
|
|
35
|
+
if (!isRecord(record) || !isRecord(record.payload))
|
|
36
|
+
return null;
|
|
37
|
+
const payload = record.payload;
|
|
38
|
+
if (payload.type !== 'token_count' || typeof record.timestamp !== 'string')
|
|
39
|
+
return null;
|
|
40
|
+
const updatedAt = Date.parse(record.timestamp);
|
|
41
|
+
if (!Number.isFinite(updatedAt))
|
|
42
|
+
return null;
|
|
43
|
+
const info = isRecord(payload.info) ? payload.info : {};
|
|
44
|
+
const totals = isRecord(info.total_token_usage) ? info.total_token_usage : {};
|
|
45
|
+
const rawLimits = isRecord(payload.rate_limits) ? payload.rate_limits : {};
|
|
46
|
+
// A rollout may carry a model-specific limit (for example GPT-5.3-Codex-Spark's
|
|
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 HandMux.
|
|
49
|
+
const limitId = typeof rawLimits.limit_id === 'string' ? rawLimits.limit_id : null;
|
|
50
|
+
const limits = limitId === null || limitId === 'codex' ? rawLimits : {};
|
|
51
|
+
return {
|
|
52
|
+
updatedAt,
|
|
53
|
+
rateLimits: {
|
|
54
|
+
primary: windowUsage(limits.primary),
|
|
55
|
+
secondary: windowUsage(limits.secondary),
|
|
56
|
+
},
|
|
57
|
+
tokens: {
|
|
58
|
+
total: numberOrNull(totals.total_tokens),
|
|
59
|
+
input: numberOrNull(totals.input_tokens),
|
|
60
|
+
cachedInput: numberOrNull(totals.cached_input_tokens),
|
|
61
|
+
output: numberOrNull(totals.output_tokens),
|
|
62
|
+
reasoning: numberOrNull(totals.reasoning_output_tokens),
|
|
63
|
+
},
|
|
64
|
+
contextWindow: numberOrNull(info.model_context_window),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function contextUsage(record) {
|
|
68
|
+
if (!isRecord(record) || !isRecord(record.payload) || record.payload.type !== 'token_count')
|
|
69
|
+
return null;
|
|
70
|
+
const info = isRecord(record.payload.info) ? record.payload.info : null;
|
|
71
|
+
const last = isRecord(info?.last_token_usage) ? info.last_token_usage : null;
|
|
72
|
+
const usedTokens = last?.total_tokens;
|
|
73
|
+
const totalTokens = info?.model_context_window;
|
|
74
|
+
if (!finiteNumber(usedTokens) || usedTokens < 0
|
|
75
|
+
|| !finiteNumber(totalTokens) || totalTokens <= 0)
|
|
76
|
+
return null;
|
|
77
|
+
return { usedTokens, totalTokens };
|
|
78
|
+
}
|
|
79
|
+
function parseLatest(lines, normalizeLine) {
|
|
80
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
81
|
+
const line = lines[i];
|
|
82
|
+
if (!line || !line.includes('token_count'))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
const usage = normalizeLine(JSON.parse(line));
|
|
86
|
+
if (usage)
|
|
87
|
+
return usage;
|
|
88
|
+
}
|
|
89
|
+
catch { /* damaged/incomplete line: continue toward older complete records */ }
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function readLatest(transcriptPath, normalizeLine) {
|
|
94
|
+
let fd;
|
|
95
|
+
try {
|
|
96
|
+
fd = fs.openSync(transcriptPath, 'r');
|
|
97
|
+
let end = fs.fstatSync(fd).size;
|
|
98
|
+
let suffix = '';
|
|
99
|
+
while (end > 0) {
|
|
100
|
+
const start = Math.max(0, end - CHUNK_BYTES);
|
|
101
|
+
const buffer = Buffer.allocUnsafe(end - start);
|
|
102
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
103
|
+
const lines = `${buffer.toString('utf8')}${suffix}`.split('\n');
|
|
104
|
+
if (start > 0)
|
|
105
|
+
suffix = lines.shift() || '';
|
|
106
|
+
const usage = parseLatest(lines, normalizeLine);
|
|
107
|
+
if (usage)
|
|
108
|
+
return usage;
|
|
109
|
+
end = start;
|
|
110
|
+
}
|
|
111
|
+
return suffix ? parseLatest([suffix], normalizeLine) : null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (fd !== undefined) {
|
|
118
|
+
try {
|
|
119
|
+
fs.closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
catch { /* best effort */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function readLatestUsage(transcriptPath) {
|
|
126
|
+
return readLatest(transcriptPath, normalize);
|
|
127
|
+
}
|
|
128
|
+
export function readLatestContextUsage(transcriptPath) {
|
|
129
|
+
return readLatest(transcriptPath, contextUsage);
|
|
130
|
+
}
|
|
131
|
+
function isUsageWindow(value) {
|
|
132
|
+
return isRecord(value)
|
|
133
|
+
&& finiteNumber(value.usedPercent)
|
|
134
|
+
&& nullableNumber(value.windowMinutes)
|
|
135
|
+
&& nullableNumber(value.resetsAt);
|
|
136
|
+
}
|
|
137
|
+
function isCodexUsage(value) {
|
|
138
|
+
if (!isRecord(value)
|
|
139
|
+
|| !finiteNumber(value.updatedAt)
|
|
140
|
+
|| !isRecord(value.rateLimits)
|
|
141
|
+
|| !isRecord(value.tokens)
|
|
142
|
+
|| !nullableNumber(value.contextWindow))
|
|
143
|
+
return false;
|
|
144
|
+
const { primary, secondary } = value.rateLimits;
|
|
145
|
+
if (primary !== null && !isUsageWindow(primary))
|
|
146
|
+
return false;
|
|
147
|
+
if (secondary !== null && !isUsageWindow(secondary))
|
|
148
|
+
return false;
|
|
149
|
+
return nullableNumber(value.tokens.total)
|
|
150
|
+
&& nullableNumber(value.tokens.input)
|
|
151
|
+
&& nullableNumber(value.tokens.cachedInput)
|
|
152
|
+
&& nullableNumber(value.tokens.output)
|
|
153
|
+
&& nullableNumber(value.tokens.reasoning);
|
|
154
|
+
}
|
|
155
|
+
export function readSnapshot(snapshotPath) {
|
|
156
|
+
try {
|
|
157
|
+
ensurePrivateDirectory(path.dirname(snapshotPath));
|
|
158
|
+
fs.chmodSync(snapshotPath, 0o600);
|
|
159
|
+
const value = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
|
|
160
|
+
if (!isRecord(value) || value.version !== 1 || !finiteNumber(value.checkedAt))
|
|
161
|
+
return null;
|
|
162
|
+
if (value.usage !== null && !isCodexUsage(value.usage))
|
|
163
|
+
return null;
|
|
164
|
+
return { version: 1, checkedAt: value.checkedAt, usage: value.usage };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function nap(ms) {
|
|
171
|
+
try {
|
|
172
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
const until = Date.now() + ms;
|
|
176
|
+
while (Date.now() < until) { /* spin */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function writeSnapshot(snapshotPath, usage, { checkedAt } = {}) {
|
|
180
|
+
try {
|
|
181
|
+
ensurePrivateDirectory(path.dirname(snapshotPath));
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const lock = `${snapshotPath}.lock`;
|
|
187
|
+
let held = false;
|
|
188
|
+
for (let i = 0; i < 40 && !held; i++) {
|
|
189
|
+
try {
|
|
190
|
+
fs.closeSync(fs.openSync(lock, 'wx', 0o600));
|
|
191
|
+
held = true;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
try {
|
|
195
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 3000)
|
|
196
|
+
fs.unlinkSync(lock);
|
|
197
|
+
}
|
|
198
|
+
catch { /* retry */ }
|
|
199
|
+
nap(5);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (!held)
|
|
203
|
+
return readSnapshot(snapshotPath)?.usage || null;
|
|
204
|
+
try {
|
|
205
|
+
const previous = readSnapshot(snapshotPath);
|
|
206
|
+
const priorUsage = previous?.usage || null;
|
|
207
|
+
const nextUsage = usage && (!priorUsage || usage.updatedAt >= priorUsage.updatedAt) ? usage : priorUsage;
|
|
208
|
+
const value = {
|
|
209
|
+
version: 1,
|
|
210
|
+
checkedAt: typeof checkedAt === 'number' ? checkedAt : (previous?.checkedAt || 0),
|
|
211
|
+
usage: nextUsage,
|
|
212
|
+
};
|
|
213
|
+
const tmp = `${snapshotPath}.${process.pid}.${Date.now()}.tmp`;
|
|
214
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
215
|
+
fs.renameSync(tmp, snapshotPath);
|
|
216
|
+
fs.chmodSync(snapshotPath, 0o600);
|
|
217
|
+
return nextUsage;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return readSnapshot(snapshotPath)?.usage || null;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
try {
|
|
224
|
+
fs.unlinkSync(lock);
|
|
225
|
+
}
|
|
226
|
+
catch { /* best effort */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { normalizeShortcuts } from './shortcutConfig.js';
|
|
2
|
+
export function loadConfig(env = process.env) {
|
|
3
|
+
return {
|
|
4
|
+
host: env.HANDMUX_HOST || '0.0.0.0',
|
|
5
|
+
port: Number(env.HANDMUX_PORT) || 4000,
|
|
6
|
+
shortcuts: normalizeShortcuts(env.HANDMUX_SHORTCUTS === undefined ? undefined : JSON.parse(env.HANDMUX_SHORTCUTS)),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
@@ -1,48 +1,46 @@
|
|
|
1
|
-
// Renderable doc types by extension. Exported so the web mirror (docPath.js DOC_LINK_EXTS) can be
|
|
2
|
-
// parity-checked against it — the client's tappable-path detector must open exactly what we can render.
|
|
3
1
|
export const EXT = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
'.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html',
|
|
3
|
+
// Plain-text files: rendered verbatim in a <pre> (no markdown parsing).
|
|
4
|
+
'.txt': 'text', '.log': 'text', '.sh': 'text',
|
|
7
5
|
};
|
|
8
|
-
|
|
9
6
|
// Map a filename to our renderable doc type by extension, or null. Case-insensitive.
|
|
10
7
|
export function docTypeFor(name) {
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
const m = /\.[A-Za-z0-9]+$/.exec(name || '');
|
|
9
|
+
return m ? (EXT[m[0].toLowerCase()] ?? null) : null;
|
|
13
10
|
}
|
|
14
|
-
|
|
15
11
|
// Image extensions the in-app viewer can show inline via <img> (GIF animates natively). SVG is safe
|
|
16
12
|
// here because <img>-loaded SVG never runs its scripts. Returns 'image' or null. Case-insensitive.
|
|
17
13
|
export const IMG_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng']);
|
|
18
14
|
export function imageTypeFor(name) {
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(name || '');
|
|
16
|
+
return m?.[1] && IMG_EXT.has(m[1].toLowerCase()) ? 'image' : null;
|
|
21
17
|
}
|
|
22
|
-
|
|
23
18
|
// True if `child` equals `parent` or sits inside it. Both are expected to be realpaths.
|
|
24
19
|
// Guards the sibling-prefix trap: /home/ab is NOT under /home/a.
|
|
25
20
|
export function isUnder(child, parent) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
if (child === parent)
|
|
22
|
+
return true;
|
|
23
|
+
const p = parent.endsWith('/') ? parent : parent + '/';
|
|
24
|
+
return child.startsWith(p);
|
|
29
25
|
}
|
|
30
|
-
|
|
31
26
|
// Sanitize a client-supplied upload filename to a single safe path segment, or null if unsafe.
|
|
32
27
|
// Takes the basename (drops any dir part, handling both / and \), then rejects empty / '.' / '..'
|
|
33
28
|
// and dotfiles (no hidden files). The result never contains a path separator.
|
|
34
29
|
export function safeUploadName(raw) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
30
|
+
if (typeof raw !== 'string')
|
|
31
|
+
return null;
|
|
32
|
+
const slashBase = raw.split('/').pop() ?? '';
|
|
33
|
+
const base = slashBase.split('\\').pop();
|
|
34
|
+
if (!base || base === '.' || base === '..' || base[0] === '.')
|
|
35
|
+
return null;
|
|
36
|
+
return base;
|
|
39
37
|
}
|
|
40
|
-
|
|
41
38
|
// True if `real` (a realpath at/under `home`) has any path segment BELOW home that starts with '.'
|
|
42
39
|
// — i.e. it lives inside a hidden directory like ~/.ssh or ~/.config. `home` is the realpath of
|
|
43
40
|
// $HOME. The home root itself is not hidden.
|
|
44
41
|
export function hasHiddenSegment(real, home) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
if (real === home)
|
|
43
|
+
return false;
|
|
44
|
+
const rel = real.startsWith(home.endsWith('/') ? home : home + '/') ? real.slice(home.length) : real;
|
|
45
|
+
return rel.split('/').filter(Boolean).some((seg) => seg.startsWith('.'));
|
|
48
46
|
}
|
package/dist/src/docs.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, basename } from 'node:path';
|
|
4
|
+
import { TextDecoder } from 'node:util';
|
|
5
|
+
import { docTypeFor, imageTypeFor, isUnder, hasHiddenSegment } from './docPath.js';
|
|
6
|
+
const MAX_READ_BYTES = 2 * 1024 * 1024; // 2MB cap for in-app text reads (readDoc)
|
|
7
|
+
// Single source of truth for the 50MB transfer cap, shared by download (maxDownloadBytes default
|
|
8
|
+
// below) and upload (httpApi.js imports this as the maxUploadBytes default). Server-side only.
|
|
9
|
+
export const MAX_TRANSFER_BYTES = 50 * 1024 * 1024;
|
|
10
|
+
// Decode only actual text. `Buffer.toString('utf8')` silently replaces malformed bytes, which would
|
|
11
|
+
// make arbitrary binaries look previewable; fatal decoders keep that boundary honest. UTF-16 needs a
|
|
12
|
+
// BOM because zero bytes are otherwise our strongest cheap binary signal.
|
|
13
|
+
function decodeText(buffer) {
|
|
14
|
+
let encoding = 'utf-8';
|
|
15
|
+
let offset = 0;
|
|
16
|
+
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
|
17
|
+
encoding = 'utf-16le';
|
|
18
|
+
offset = 2;
|
|
19
|
+
}
|
|
20
|
+
else if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
|
|
21
|
+
encoding = 'utf-16be';
|
|
22
|
+
offset = 2;
|
|
23
|
+
}
|
|
24
|
+
else if (buffer.includes(0)) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return new TextDecoder(encoding, { fatal: true }).decode(buffer.subarray(offset));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Flatten an absolute cwd into ONE filesystem-safe path segment for the per-project upload space,
|
|
35
|
+
// Claude-Code style: '/' → '-' (so /Users/x/proj → -Users-x-proj), and any other non-portable char
|
|
36
|
+
// → '_'. A valid cwd always starts with '/', so the key always starts with '-' — it can never be
|
|
37
|
+
// '..'/'.' and, having no separators, can never escape the uploads root. Empty/relative → '_default'.
|
|
38
|
+
export function encodeCwdKey(cwd) {
|
|
39
|
+
if (typeof cwd !== 'string' || cwd[0] !== '/')
|
|
40
|
+
return '_default';
|
|
41
|
+
return cwd.replace(/\//g, '-').replace(/[^A-Za-z0-9._-]/g, '_') || '_default';
|
|
42
|
+
}
|
|
43
|
+
export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANSFER_BYTES }) {
|
|
44
|
+
// $HOME is constant at runtime — resolve once at construction, reuse the same promise everywhere.
|
|
45
|
+
const realHomeP = fs.realpath(home);
|
|
46
|
+
// Browse / read / download / upload may also reach a few EXTRA roots OUTSIDE $HOME (e.g. /tmp,
|
|
47
|
+
// $TMPDIR) so transient files an agent drops there are reachable from the phone. Resolved once:
|
|
48
|
+
// realpath'd, deduped, missing ones skipped, and any extra already inside home dropped (home
|
|
49
|
+
// covers it). `home` is always roots[0]. Session-cwd (resolveCwd) and the upload stash stay
|
|
50
|
+
// home-only on purpose. The "under one of these roots" check replaces the old single isUnder(home).
|
|
51
|
+
const rootsP = (async () => {
|
|
52
|
+
const rh = await realHomeP;
|
|
53
|
+
const out = [rh];
|
|
54
|
+
for (const r of extraRoots) {
|
|
55
|
+
if (typeof r !== 'string' || !r)
|
|
56
|
+
continue;
|
|
57
|
+
let real;
|
|
58
|
+
try {
|
|
59
|
+
real = await fs.realpath(r);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
} // not present on this host → skip
|
|
64
|
+
if (isUnder(real, rh) || out.includes(real))
|
|
65
|
+
continue; // already covered by home / dup
|
|
66
|
+
out.push(real);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
})();
|
|
70
|
+
// The allowed root that contains `real` (longest match wins should roots ever nest), or null.
|
|
71
|
+
const rootOf = (real, roots) => {
|
|
72
|
+
let best = null;
|
|
73
|
+
for (const r of roots)
|
|
74
|
+
if (isUnder(real, r) && (!best || r.length > best.length))
|
|
75
|
+
best = r;
|
|
76
|
+
return best;
|
|
77
|
+
};
|
|
78
|
+
// `knownMtime` (ms) makes this a conditional read: if the file's mtime is unchanged the body is
|
|
79
|
+
// skipped and `{ notModified: true }` comes back (no readFile), so re-visiting an unchanged doc on
|
|
80
|
+
// the phone neither transfers content nor forces a re-render. `mtimeMs` is always returned so the
|
|
81
|
+
// client can store it for the next check.
|
|
82
|
+
async function readDoc(rawPath, knownMtime = null) {
|
|
83
|
+
if (typeof rawPath !== 'string' || rawPath[0] !== '/')
|
|
84
|
+
return { error: 'not absolute', status: 400 };
|
|
85
|
+
// Markdown/HTML keep their rich renderer; every other filename gets a content-checked plain-text
|
|
86
|
+
// preview. Extension is presentation metadata, never the permission to read a file as text.
|
|
87
|
+
const type = docTypeFor(rawPath) || 'text';
|
|
88
|
+
let real;
|
|
89
|
+
try {
|
|
90
|
+
real = await fs.realpath(rawPath);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { error: 'not found', status: 404 };
|
|
94
|
+
}
|
|
95
|
+
if (!rootOf(real, await rootsP))
|
|
96
|
+
return { error: 'outside home', status: 400 };
|
|
97
|
+
let st;
|
|
98
|
+
try {
|
|
99
|
+
st = await fs.stat(real);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { error: 'not accessible', status: 404 };
|
|
103
|
+
}
|
|
104
|
+
if (!st.isFile())
|
|
105
|
+
return { error: 'not a file', status: 400 };
|
|
106
|
+
if (st.size > MAX_READ_BYTES)
|
|
107
|
+
return { error: 'too large', status: 413 };
|
|
108
|
+
const mtimeMs = st.mtimeMs;
|
|
109
|
+
if (knownMtime != null && mtimeMs === knownMtime)
|
|
110
|
+
return { name: basename(real), type, mtimeMs, notModified: true };
|
|
111
|
+
const content = decodeText(await fs.readFile(real));
|
|
112
|
+
if (content == null)
|
|
113
|
+
return { error: 'not text', status: 415 };
|
|
114
|
+
return { name: basename(real), type, content, mtimeMs };
|
|
115
|
+
}
|
|
116
|
+
async function listDir(rawPath) {
|
|
117
|
+
const rh = await realHomeP;
|
|
118
|
+
const roots = await rootsP;
|
|
119
|
+
let real;
|
|
120
|
+
try {
|
|
121
|
+
real = await fs.realpath(rawPath ? rawPath : home);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { error: 'not found', status: 404 };
|
|
125
|
+
}
|
|
126
|
+
if (!rootOf(real, roots))
|
|
127
|
+
return { error: 'outside home', status: 400 };
|
|
128
|
+
const st = await fs.stat(real);
|
|
129
|
+
if (!st.isDirectory())
|
|
130
|
+
return { error: 'not a directory', status: 400 };
|
|
131
|
+
const dirents = await fs.readdir(real, { withFileTypes: true });
|
|
132
|
+
// withFileTypes uses lstat semantics: d.isFile() is false for symlinks, so symlinks are
|
|
133
|
+
// intentionally NOT listed (security property). Every regular file is listed now (not just
|
|
134
|
+
// docs); the extension decides whether it opens in-app as a doc ('doc'), an image viewer
|
|
135
|
+
// ('image'), or can only be downloaded ('file').
|
|
136
|
+
const dirEntries = [];
|
|
137
|
+
const fileDirents = [];
|
|
138
|
+
for (const d of dirents) {
|
|
139
|
+
if (d.isDirectory())
|
|
140
|
+
dirEntries.push({ name: d.name, type: 'dir' });
|
|
141
|
+
else if (d.isFile())
|
|
142
|
+
fileDirents.push(d);
|
|
143
|
+
}
|
|
144
|
+
// stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
|
|
145
|
+
// (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
|
|
146
|
+
const fileEntries = await Promise.all(fileDirents.map(async (d) => {
|
|
147
|
+
const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
|
|
148
|
+
let size = 0;
|
|
149
|
+
try {
|
|
150
|
+
size = (await fs.stat(join(real, d.name))).size;
|
|
151
|
+
}
|
|
152
|
+
catch { /* gone mid-list → size 0 */ }
|
|
153
|
+
return { name: d.name, type, size };
|
|
154
|
+
}));
|
|
155
|
+
const entries = [...dirEntries, ...fileEntries];
|
|
156
|
+
// dirs first; files (doc+file) interleaved alphabetically
|
|
157
|
+
entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
158
|
+
// "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
|
|
159
|
+
const parent = roots.includes(real) ? null : join(real, '..');
|
|
160
|
+
return { path: real, home: rh, roots, parent, entries };
|
|
161
|
+
}
|
|
162
|
+
// Resolve a path for DOWNLOAD: any regular file under home, no extension white-list (unlike
|
|
163
|
+
// readDoc). Same realpath+isUnder boundary; symlinks escaping home resolve outside → rejected.
|
|
164
|
+
async function statForDownload(rawPath) {
|
|
165
|
+
if (typeof rawPath !== 'string' || rawPath[0] !== '/')
|
|
166
|
+
return { error: 'not absolute', status: 400 };
|
|
167
|
+
let real;
|
|
168
|
+
try {
|
|
169
|
+
real = await fs.realpath(rawPath);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { error: 'not found', status: 404 };
|
|
173
|
+
}
|
|
174
|
+
if (!rootOf(real, await rootsP))
|
|
175
|
+
return { error: 'outside home', status: 400 };
|
|
176
|
+
let st;
|
|
177
|
+
try {
|
|
178
|
+
st = await fs.stat(real);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { error: 'not accessible', status: 404 };
|
|
182
|
+
}
|
|
183
|
+
if (!st.isFile())
|
|
184
|
+
return { error: 'not a file', status: 400 };
|
|
185
|
+
if (st.size > maxDownloadBytes)
|
|
186
|
+
return { error: 'too large', status: 413 };
|
|
187
|
+
return { real, name: basename(real), size: st.size, mtimeMs: st.mtimeMs };
|
|
188
|
+
}
|
|
189
|
+
// Resolve a path for UPLOAD TARGET: a directory under one of the allowed roots, not inside any
|
|
190
|
+
// hidden directory (relative to its own root). The $HOME root itself is off-limits (don't litter
|
|
191
|
+
// the home dir); an extra root like /tmp IS uploadable directly, since dropping files there is the
|
|
192
|
+
// whole point. Same realpath boundary as the rest.
|
|
193
|
+
async function resolveUploadDir(rawDir) {
|
|
194
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/')
|
|
195
|
+
return { error: 'not absolute', status: 400 };
|
|
196
|
+
const rh = await realHomeP;
|
|
197
|
+
let real;
|
|
198
|
+
try {
|
|
199
|
+
real = await fs.realpath(rawDir);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { error: 'not found', status: 404 };
|
|
203
|
+
}
|
|
204
|
+
const root = rootOf(real, await rootsP);
|
|
205
|
+
if (!root)
|
|
206
|
+
return { error: 'outside home', status: 400 };
|
|
207
|
+
if (real === rh)
|
|
208
|
+
return { error: 'home root not allowed', status: 400 };
|
|
209
|
+
if (hasHiddenSegment(real, root))
|
|
210
|
+
return { error: 'hidden directory not allowed', status: 400 };
|
|
211
|
+
let st;
|
|
212
|
+
try {
|
|
213
|
+
st = await fs.stat(real);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { error: 'not accessible', status: 404 };
|
|
217
|
+
}
|
|
218
|
+
if (!st.isDirectory())
|
|
219
|
+
return { error: 'not a directory', status: 400 };
|
|
220
|
+
return { real };
|
|
221
|
+
}
|
|
222
|
+
// Resolve the STASH upload target: a per-project folder under ~/.handmux/uploads, created on
|
|
223
|
+
// demand. Deliberately OUTSIDE the user's project trees (so uploads can never be `git add`-ed by
|
|
224
|
+
// accident) yet still under $HOME, so the absolute path the chat box pastes stays readable by an
|
|
225
|
+
// agent running anywhere. Mirrors Claude Code's per-project layout: the caller's cwd is flattened
|
|
226
|
+
// ('/'→'-') into a single path segment (see encodeCwdKey), so each working directory gets its own
|
|
227
|
+
// space; an unknown cwd falls into `_default`. Returns the realpath'd dir.
|
|
228
|
+
async function resolveStashDir(rawCwd) {
|
|
229
|
+
const rh = await realHomeP;
|
|
230
|
+
const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
|
|
231
|
+
try {
|
|
232
|
+
await fs.mkdir(target, { recursive: true });
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { error: 'mkdir failed', status: 500 };
|
|
236
|
+
}
|
|
237
|
+
let real;
|
|
238
|
+
try {
|
|
239
|
+
real = await fs.realpath(target);
|
|
240
|
+
} // re-resolve in case a segment is a symlink
|
|
241
|
+
catch {
|
|
242
|
+
return { error: 'not accessible', status: 404 };
|
|
243
|
+
}
|
|
244
|
+
if (!isUnder(real, rh))
|
|
245
|
+
return { error: 'outside home', status: 400 };
|
|
246
|
+
return { real };
|
|
247
|
+
}
|
|
248
|
+
// Resolve a path for use as a NEW session/window CWD: any directory under home (home root IS
|
|
249
|
+
// allowed, and hidden dirs ARE allowed — the browser can navigate into them). Same realpath +
|
|
250
|
+
// isUnder boundary as the rest; symlinks escaping home resolve outside → rejected.
|
|
251
|
+
async function resolveCwd(rawDir) {
|
|
252
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/')
|
|
253
|
+
return { error: 'not absolute', status: 400 };
|
|
254
|
+
const rh = await realHomeP;
|
|
255
|
+
let real;
|
|
256
|
+
try {
|
|
257
|
+
real = await fs.realpath(rawDir);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return { error: 'not found', status: 404 };
|
|
261
|
+
}
|
|
262
|
+
if (!isUnder(real, rh))
|
|
263
|
+
return { error: 'outside home', status: 400 };
|
|
264
|
+
let st;
|
|
265
|
+
try {
|
|
266
|
+
st = await fs.stat(real);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return { error: 'not accessible', status: 404 };
|
|
270
|
+
}
|
|
271
|
+
if (!st.isDirectory())
|
|
272
|
+
return { error: 'not a directory', status: 400 };
|
|
273
|
+
return { real };
|
|
274
|
+
}
|
|
275
|
+
// Resolve a directory for BROWSE-side ops (the mkdir target): a directory under ANY allowed root
|
|
276
|
+
// (the root itself + hidden dirs allowed, like resolveCwd but multi-root). resolveCwd stays
|
|
277
|
+
// home-only — a new session's cwd should never land in a temp root by accident.
|
|
278
|
+
async function resolveBrowseDir(rawDir) {
|
|
279
|
+
if (typeof rawDir !== 'string' || rawDir[0] !== '/')
|
|
280
|
+
return { error: 'not absolute', status: 400 };
|
|
281
|
+
let real;
|
|
282
|
+
try {
|
|
283
|
+
real = await fs.realpath(rawDir);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return { error: 'not found', status: 404 };
|
|
287
|
+
}
|
|
288
|
+
if (!rootOf(real, await rootsP))
|
|
289
|
+
return { error: 'outside home', status: 400 };
|
|
290
|
+
let st;
|
|
291
|
+
try {
|
|
292
|
+
st = await fs.stat(real);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return { error: 'not accessible', status: 404 };
|
|
296
|
+
}
|
|
297
|
+
if (!st.isDirectory())
|
|
298
|
+
return { error: 'not a directory', status: 400 };
|
|
299
|
+
return { real };
|
|
300
|
+
}
|
|
301
|
+
// Create a new directory `name` inside `parentRaw`. The parent must be a directory under one of
|
|
302
|
+
// the allowed roots (root + hidden dirs allowed). `name` must be a single safe path segment.
|
|
303
|
+
async function makeDir(parentRaw, name) {
|
|
304
|
+
const nm = typeof name === 'string' ? name.trim() : '';
|
|
305
|
+
if (!nm || nm === '.' || nm === '..' || nm.includes('/') || nm.includes('\\') || nm.includes('\0')) {
|
|
306
|
+
return { error: 'bad name', status: 400 };
|
|
307
|
+
}
|
|
308
|
+
const parent = await resolveBrowseDir(parentRaw); // realpath + under-a-root + isDirectory
|
|
309
|
+
if ('error' in parent)
|
|
310
|
+
return parent;
|
|
311
|
+
const target = join(parent.real, nm);
|
|
312
|
+
try {
|
|
313
|
+
await fs.mkdir(target);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
if (errorCode(e) === 'EEXIST')
|
|
317
|
+
return { error: 'exists', status: 409 };
|
|
318
|
+
return { error: 'mkdir failed', status: 500 };
|
|
319
|
+
}
|
|
320
|
+
return { real: target };
|
|
321
|
+
}
|
|
322
|
+
return { readDoc, listDir, statForDownload, resolveUploadDir, resolveStashDir, resolveCwd, makeDir };
|
|
323
|
+
}
|
|
324
|
+
// The extra (outside-$HOME) roots the file browser may reach: the system temp dir and, if set, the
|
|
325
|
+
// per-user $TMPDIR (on macOS that's /var/folders/.../T). Missing ones are skipped at resolve time.
|
|
326
|
+
function errorCode(error) {
|
|
327
|
+
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
328
|
+
return undefined;
|
|
329
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
330
|
+
}
|
|
331
|
+
export function defaultExtraRoots(env = process.env) {
|
|
332
|
+
const roots = ['/tmp'];
|
|
333
|
+
if (env.TMPDIR)
|
|
334
|
+
roots.push(env.TMPDIR);
|
|
335
|
+
return roots;
|
|
336
|
+
}
|
|
337
|
+
export const defaultDocs = createDocs({ home: homedir(), extraRoots: defaultExtraRoots() });
|