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/httpApi.js
CHANGED
|
@@ -30,7 +30,7 @@ import { projectTaskRoutes } from './projectTask/routes.js';
|
|
|
30
30
|
import { apiAccountRoutes } from './routes/apiAccounts.js';
|
|
31
31
|
// Re-exported for tests (test/keys.test.js) and any caller that imported it by this path historically.
|
|
32
32
|
export { isAllowedKey } from './routes/terminal.js';
|
|
33
|
-
export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
|
|
33
|
+
export function createApiRouter({ token, commands = defaultCommands, docs = defaultDocs, git = defaultGit, events, authentication, deviceAuth = false, uploadExts = DEFAULT_UPLOAD_EXTS, maxUploadBytes = MAX_TRANSFER_BYTES, asrEnv = process.env, previews, shortcuts = DEFAULT_SHORTCUTS, shortcutState, browser, browserBootstrap, previewDomain, workspace, agentRuntime, projectTask, apiAccounts, apiErrors, agentIntegrationContext, home = homedir(), stateFile = process.env.CLAUDE_STATE_FILE || claudeStatePath(homedir()), }) {
|
|
34
34
|
const r = express.Router();
|
|
35
35
|
r.use(apiRequestContext(apiErrors));
|
|
36
36
|
// Account responses, including auth failures and malformed JSON, must never be cached. Keep the
|
|
@@ -38,6 +38,10 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
|
|
|
38
38
|
if (apiAccounts) {
|
|
39
39
|
r.use('/api-accounts', (req, res, next) => {
|
|
40
40
|
res.setHeader('Cache-Control', 'no-store');
|
|
41
|
+
if (authentication) {
|
|
42
|
+
next();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
41
45
|
const provided = bearerFrom(req.get('authorization'));
|
|
42
46
|
if (provided && tokenEquals(provided, token))
|
|
43
47
|
next();
|
|
@@ -47,7 +51,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
|
|
|
47
51
|
});
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
|
-
r.use(expressAuth(token));
|
|
54
|
+
r.use(authentication ?? expressAuth(token));
|
|
51
55
|
r.use(express.json());
|
|
52
56
|
const eventOptions = { commands, push };
|
|
53
57
|
const claudeEvents = events || createClaudeEvents(eventOptions);
|
|
@@ -59,7 +63,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
|
|
|
59
63
|
})))),
|
|
60
64
|
};
|
|
61
65
|
const deps = {
|
|
62
|
-
token, commands, docs, git, push, notifications, claudeEvents,
|
|
66
|
+
token, commands, docs, git, push, notifications, claudeEvents, deviceAuth,
|
|
63
67
|
agentIdentity: agentRuntime ?? legacyAgentIdentity,
|
|
64
68
|
uploadExts, maxUploadBytes, asrEnv, shortcuts, home, stateFile,
|
|
65
69
|
...(agentRuntime !== undefined ? { agentRuntime } : {}),
|
|
@@ -69,6 +73,7 @@ export function createApiRouter({ token, commands = defaultCommands, docs = defa
|
|
|
69
73
|
...(browserBootstrap !== undefined ? { browserBootstrap } : {}),
|
|
70
74
|
...(previewDomain !== undefined ? { previewDomain } : {}),
|
|
71
75
|
...(agentIntegrationContext !== undefined ? { agentIntegrationContext } : {}),
|
|
76
|
+
...(shortcutState ? { shortcutState } : {}),
|
|
72
77
|
};
|
|
73
78
|
r.use(sessionRoutes(deps));
|
|
74
79
|
r.use(terminalRoutes(deps));
|
package/dist/src/paneInput.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertRequestAuthority } from './requestAuthority.js';
|
|
1
2
|
const SUBMIT_GAP_MS = 120;
|
|
2
3
|
const paneInputTails = new Map();
|
|
3
4
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -5,7 +6,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
5
6
|
// text + submit in the same operation prevents two clients from interleaving `paste-buffer` and Enter.
|
|
6
7
|
export async function serializePaneInput(paneId, operation) {
|
|
7
8
|
const previous = paneInputTails.get(paneId) ?? Promise.resolve();
|
|
8
|
-
const current = previous.catch(() => { }).then(operation);
|
|
9
|
+
const current = previous.catch(() => { }).then(() => { assertRequestAuthority(); return operation(); });
|
|
9
10
|
const tail = current.then(() => { }, () => { });
|
|
10
11
|
paneInputTails.set(paneId, tail);
|
|
11
12
|
try {
|
|
@@ -21,9 +22,11 @@ export function sendPanePrompt(commands, paneId, text, guard) {
|
|
|
21
22
|
if (guard && !await guard.validate())
|
|
22
23
|
return { nativeMutation: false };
|
|
23
24
|
await commands.exitCopyModeIfActive(paneId);
|
|
25
|
+
assertRequestAuthority();
|
|
24
26
|
await commands.sendText(paneId, text);
|
|
25
27
|
if (text)
|
|
26
28
|
await delay(SUBMIT_GAP_MS);
|
|
29
|
+
assertRequestAuthority();
|
|
27
30
|
await commands.sendEnter(paneId);
|
|
28
31
|
return { nativeMutation: true };
|
|
29
32
|
});
|
|
@@ -31,12 +34,23 @@ export function sendPanePrompt(commands, paneId, text, guard) {
|
|
|
31
34
|
export function interruptPane(commands, paneId) {
|
|
32
35
|
return serializePaneInput(paneId, async () => {
|
|
33
36
|
await commands.exitCopyModeIfActive(paneId);
|
|
37
|
+
assertRequestAuthority();
|
|
34
38
|
await commands.sendKey(paneId, 'C-c');
|
|
35
39
|
});
|
|
36
40
|
}
|
|
41
|
+
export function interruptClaudePane(commands, paneId) {
|
|
42
|
+
return serializePaneInput(paneId, async () => {
|
|
43
|
+
await commands.exitCopyModeIfActive(paneId);
|
|
44
|
+
await commands.sendKey(paneId, 'Escape');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
export function sendPaneChoice(commands, paneId, choice) {
|
|
48
|
+
if (!/^[1-9]$/.test(choice))
|
|
49
|
+
throw new TypeError('Pane choice requires a single option digit');
|
|
38
50
|
return serializePaneInput(paneId, async () => {
|
|
39
51
|
await commands.exitCopyModeIfActive(paneId);
|
|
40
|
-
|
|
52
|
+
assertRequestAuthority();
|
|
53
|
+
// Menu shortcuts are key events; bracketed paste is not handled by Claude's selector.
|
|
54
|
+
await commands.sendKey(paneId, choice);
|
|
41
55
|
});
|
|
42
56
|
}
|
|
@@ -76,8 +76,25 @@ const errorCode = (error) => {
|
|
|
76
76
|
};
|
|
77
77
|
export function createPreview({ previews }) {
|
|
78
78
|
const router = express.Router();
|
|
79
|
+
const deviceResponses = new Map();
|
|
80
|
+
previews.onRevoke?.((deviceId) => {
|
|
81
|
+
for (const response of deviceResponses.get(deviceId) || [])
|
|
82
|
+
response.destroy();
|
|
83
|
+
deviceResponses.delete(deviceId);
|
|
84
|
+
});
|
|
85
|
+
function track(entry, res) {
|
|
86
|
+
if (!entry.deviceId)
|
|
87
|
+
return;
|
|
88
|
+
const id = entry.deviceId;
|
|
89
|
+
let responses = deviceResponses.get(id);
|
|
90
|
+
if (!responses)
|
|
91
|
+
deviceResponses.set(id, responses = new Set());
|
|
92
|
+
responses.add(res);
|
|
93
|
+
res.once('close', () => { responses.delete(res); if (!responses.size)
|
|
94
|
+
deviceResponses.delete(id); });
|
|
95
|
+
}
|
|
79
96
|
function resolveEntry(name, accessToken, res) {
|
|
80
|
-
const lookup = previews.get(name);
|
|
97
|
+
const lookup = previews.get(name, accessToken);
|
|
81
98
|
if (lookup.state === 'missing') {
|
|
82
99
|
res.status(404).type('html').send('<!doctype html><meta charset="utf-8"><h1>预览不存在</h1>');
|
|
83
100
|
return null;
|
|
@@ -93,6 +110,7 @@ export function createPreview({ previews }) {
|
|
|
93
110
|
return lookup.entry;
|
|
94
111
|
}
|
|
95
112
|
async function serve(entry, rest, prefix, res) {
|
|
113
|
+
track(entry, res);
|
|
96
114
|
previewHeaders(res);
|
|
97
115
|
const requested = fileFor(rest);
|
|
98
116
|
const segments = requested.split('/');
|
|
@@ -112,6 +130,11 @@ export function createPreview({ previews }) {
|
|
|
112
130
|
if (['.html', '.htm', '.css', '.js', '.mjs'].includes(extension)) {
|
|
113
131
|
try {
|
|
114
132
|
const source = await fs.readFile(target, 'utf8');
|
|
133
|
+
if (res.destroyed)
|
|
134
|
+
return;
|
|
135
|
+
// File reads yield: a device may have been revoked while the file was being loaded.
|
|
136
|
+
if (!resolveEntry(entry.name, entry.accessToken || '', res))
|
|
137
|
+
return;
|
|
115
138
|
res.type(extension).send(rewritePreviewText(source, extension, prefix));
|
|
116
139
|
}
|
|
117
140
|
catch (error) {
|
|
@@ -187,11 +210,12 @@ export function createPreview({ previews }) {
|
|
|
187
210
|
catch {
|
|
188
211
|
return next();
|
|
189
212
|
}
|
|
190
|
-
const lookup = previews.get(name);
|
|
213
|
+
const lookup = previews.get(name, accessToken);
|
|
191
214
|
if (lookup.state !== 'active')
|
|
192
215
|
return next();
|
|
193
216
|
if (!credentialOk(accessToken, lookup.entry.accessToken))
|
|
194
217
|
return next();
|
|
218
|
+
track(lookup.entry, res);
|
|
195
219
|
previewHeaders(res);
|
|
196
220
|
res.sendFile(req.path, { root: lookup.entry.dir, dotfiles: 'deny' }, (err) => {
|
|
197
221
|
if (err && !res.headersSent)
|
package/dist/src/previews.js
CHANGED
|
@@ -45,6 +45,8 @@ function parsePreviewEntry(value, realHome) {
|
|
|
45
45
|
if (!isUnder(realDir, realHome))
|
|
46
46
|
return null;
|
|
47
47
|
const entry = { name, dir: value.dir, expiresAt: value.expiresAt };
|
|
48
|
+
if (typeof value.deviceId === 'string' && value.deviceId)
|
|
49
|
+
entry.deviceId = value.deviceId;
|
|
48
50
|
if (value.kind === 'static')
|
|
49
51
|
entry.kind = 'static';
|
|
50
52
|
if (typeof value.accessToken === 'string' && value.accessToken)
|
|
@@ -53,7 +55,7 @@ function parsePreviewEntry(value, realHome) {
|
|
|
53
55
|
entry.createdAt = value.createdAt;
|
|
54
56
|
return entry;
|
|
55
57
|
}
|
|
56
|
-
export function createPreviews({ home = homedir(), store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'), now = () => Date.now(), ttlMs = 2 * 60 * 60_000, randomToken = () => randomBytes(24).toString('base64url'), } = {}) {
|
|
58
|
+
export function createPreviews({ home = homedir(), store = process.env.PREVIEW_STORE || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../data/previews.json'), now = () => Date.now(), ttlMs = 2 * 60 * 60_000, randomToken = () => randomBytes(24).toString('base64url'), isDeviceActive, } = {}) {
|
|
57
59
|
let realHome;
|
|
58
60
|
try {
|
|
59
61
|
realHome = fs.realpathSync(home);
|
|
@@ -65,6 +67,13 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
65
67
|
let entries = readJsonArray(store)
|
|
66
68
|
.map((value) => parsePreviewEntry(value, realHome))
|
|
67
69
|
.filter((entry) => entry !== null);
|
|
70
|
+
// Never inherit capabilities from a previous mode/process, even from legacy files containing tokens.
|
|
71
|
+
for (const entry of entries)
|
|
72
|
+
if (entry.kind !== 'dynamic')
|
|
73
|
+
delete entry.accessToken;
|
|
74
|
+
const authorized = (entry) => isDeviceActive
|
|
75
|
+
? Boolean(entry.deviceId && isDeviceActive(entry.deviceId)) : !entry.deviceId;
|
|
76
|
+
const revokeListeners = new Set();
|
|
68
77
|
let flushedExpiries = new Map(entries.map((entry) => [entry.name, entry.expiresAt]));
|
|
69
78
|
const flush = () => {
|
|
70
79
|
// Access tokens are runtime capabilities. Open device tabs re-register after a restart and receive
|
|
@@ -88,7 +97,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
88
97
|
// directory, expired row, or process-restored row without a runtime token receives a fresh one.
|
|
89
98
|
const upsert = (fields) => {
|
|
90
99
|
const ts = now();
|
|
91
|
-
const current = entries.find((entry) => entry.name === fields.name);
|
|
100
|
+
const current = entries.find((entry) => entry.name === fields.name && entry.kind !== 'dynamic' && entry.deviceId === fields.deviceId);
|
|
92
101
|
if (current?.kind === fields.kind
|
|
93
102
|
&& current.dir === fields.dir
|
|
94
103
|
&& current.expiresAt > ts
|
|
@@ -98,7 +107,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
98
107
|
flush();
|
|
99
108
|
return resultFor(current);
|
|
100
109
|
}
|
|
101
|
-
entries = entries.filter((entry) => entry.name !== fields.name);
|
|
110
|
+
entries = entries.filter((entry) => entry.name !== fields.name || (entry.kind !== 'dynamic' && entry.deviceId !== fields.deviceId));
|
|
102
111
|
const entry = {
|
|
103
112
|
...fields,
|
|
104
113
|
accessToken: randomToken(),
|
|
@@ -109,7 +118,7 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
109
118
|
flush();
|
|
110
119
|
return resultFor(entry);
|
|
111
120
|
};
|
|
112
|
-
async function register({ name, dir, port }) {
|
|
121
|
+
async function register({ name, dir, port }, deviceId) {
|
|
113
122
|
const nm = safePreviewName(name);
|
|
114
123
|
if (!nm)
|
|
115
124
|
return { error: 'bad name', status: 400 };
|
|
@@ -135,15 +144,20 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
135
144
|
}
|
|
136
145
|
if (!st.isDirectory())
|
|
137
146
|
return { error: 'not a directory', status: 400 };
|
|
138
|
-
|
|
147
|
+
if (isDeviceActive && (!deviceId || !isDeviceActive(deviceId)))
|
|
148
|
+
return { error: 'device authorization expired', status: 401 };
|
|
149
|
+
return upsert({ name: nm, kind: 'static', dir: real, ...(isDeviceActive && deviceId ? { deviceId } : {}) });
|
|
139
150
|
}
|
|
140
|
-
function get(name) {
|
|
141
|
-
const entry = entries.find((candidate) => candidate.name === name
|
|
151
|
+
function get(name, accessToken) {
|
|
152
|
+
const entry = entries.find((candidate) => candidate.name === name
|
|
153
|
+
&& (!isDeviceActive || (candidate.kind !== 'dynamic' && candidate.accessToken === accessToken)));
|
|
142
154
|
if (!entry)
|
|
143
155
|
return { state: 'missing' };
|
|
156
|
+
if (entry.kind !== 'dynamic' && !authorized(entry))
|
|
157
|
+
return { state: 'expired' };
|
|
144
158
|
const ts = now();
|
|
145
159
|
if (entry.expiresAt <= ts) {
|
|
146
|
-
entries = entries.filter((candidate) => candidate
|
|
160
|
+
entries = entries.filter((candidate) => candidate !== entry);
|
|
147
161
|
flush();
|
|
148
162
|
return { state: 'expired' };
|
|
149
163
|
}
|
|
@@ -160,20 +174,30 @@ export function createPreviews({ home = homedir(), store = process.env.PREVIEW_S
|
|
|
160
174
|
flush();
|
|
161
175
|
return { state: 'active', entry: { ...entry, kind: 'static' } }; // legacy rows (no kind) → static
|
|
162
176
|
}
|
|
163
|
-
function list() {
|
|
177
|
+
function list(deviceId) {
|
|
164
178
|
const active = entries.filter((entry) => entry.kind !== 'dynamic' && entry.expiresAt > now());
|
|
165
179
|
if (active.length !== entries.length) {
|
|
166
180
|
entries = active;
|
|
167
181
|
flush();
|
|
168
182
|
}
|
|
169
|
-
return active.
|
|
183
|
+
return active.filter((entry) => authorized(entry) && (!isDeviceActive || entry.deviceId === deviceId))
|
|
184
|
+
.map((entry) => ({ name: entry.name, kind: 'static', dir: entry.dir, expiresAt: entry.expiresAt }));
|
|
170
185
|
}
|
|
171
|
-
function remove(name) {
|
|
172
|
-
const next = entries.filter((entry) => entry.name !== name);
|
|
186
|
+
function remove(name, deviceId) {
|
|
187
|
+
const next = entries.filter((entry) => entry.name !== name || (isDeviceActive && (entry.kind === 'dynamic' || !deviceId || entry.deviceId !== deviceId)));
|
|
173
188
|
if (next.length !== entries.length) {
|
|
174
189
|
entries = next;
|
|
175
190
|
flush();
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
|
-
|
|
193
|
+
function revokeDevice(deviceId) {
|
|
194
|
+
for (const entry of entries)
|
|
195
|
+
if (entry.kind !== 'dynamic' && entry.deviceId === deviceId)
|
|
196
|
+
delete entry.accessToken;
|
|
197
|
+
for (const listener of revokeListeners)
|
|
198
|
+
listener(deviceId);
|
|
199
|
+
}
|
|
200
|
+
return { register, get, list, remove, revokeDevice,
|
|
201
|
+
onRevoke(listener) { revokeListeners.add(listener); return () => { revokeListeners.delete(listener); }; },
|
|
202
|
+
};
|
|
179
203
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { migrateProjectDatabase } from './migrations.js';
|
|
4
5
|
const sqlite = createRequire(import.meta.url)('node:sqlite');
|
|
5
6
|
function stamp(date) {
|
|
6
7
|
return date.toISOString().replace(/[:.]/g, '-');
|
|
@@ -161,6 +162,33 @@ export async function restoreProjectBackup({ backupPath, databasePath, fs = fsp,
|
|
|
161
162
|
const temporary = `${databasePath}.restore`;
|
|
162
163
|
await fs.copyFile(backupPath, temporary);
|
|
163
164
|
await fs.chmod(temporary, 0o600);
|
|
165
|
+
// Revoke before publishing the restored file. A crash at any later point cannot resurrect access.
|
|
166
|
+
const restored = new sqlite.DatabaseSync(temporary, { allowExtension: false });
|
|
167
|
+
try {
|
|
168
|
+
// Revoke credentials before publishing the restored file so a crash at any later point cannot
|
|
169
|
+
// resurrect access from a backup snapshot.
|
|
170
|
+
migrateProjectDatabase(restored);
|
|
171
|
+
const hasAuth = restored.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='auth_sessions'").get();
|
|
172
|
+
if (hasAuth) {
|
|
173
|
+
restored.exec('BEGIN IMMEDIATE');
|
|
174
|
+
try {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
restored.prepare('UPDATE auth_sessions SET revoked_at = ? WHERE revoked_at IS NULL').run(now);
|
|
177
|
+
restored.prepare('UPDATE auth_devices SET revoked_at = ? WHERE revoked_at IS NULL').run(now);
|
|
178
|
+
restored.prepare("DELETE FROM auth_meta WHERE key='token_generation'").run();
|
|
179
|
+
restored.exec('COMMIT');
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
restored.exec('ROLLBACK');
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
restored.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
187
|
+
restored.exec('PRAGMA journal_mode = DELETE');
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
restored.close();
|
|
191
|
+
}
|
|
164
192
|
await fs.rename(temporary, databasePath);
|
|
165
193
|
await fs.rm(recoveryMarkerPath(databasePath), { force: true });
|
|
166
194
|
}
|
|
@@ -99,7 +99,7 @@ export function createProjectStoreLock({ dir, fs = fsp, pid = process.pid, now =
|
|
|
99
99
|
if (await reclaimStale(observed))
|
|
100
100
|
continue;
|
|
101
101
|
const pidLabel = observed?.owner?.pid ? ` (pid ${observed.owner.pid})` : '';
|
|
102
|
-
throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, `Project data is open in another
|
|
102
|
+
throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, `Project data is open in another handmux instance${pidLabel}; close it and try again`);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
throw new ProjectTaskError('PROJECT_STORE_LOCKED', 503, 'Project data lock changed while starting; try again');
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import { PROJECT_TASK_SCHEMA_VERSION, ProjectTaskError } from './schema.js';
|
|
2
|
+
const V2_AUTH_SCHEMA = `
|
|
3
|
+
CREATE TABLE auth_devices (
|
|
4
|
+
id TEXT PRIMARY KEY, pairing_request_id TEXT NOT NULL UNIQUE,
|
|
5
|
+
name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 80),
|
|
6
|
+
browser_summary TEXT NOT NULL, authorized_at INTEGER NOT NULL,
|
|
7
|
+
expires_at INTEGER, last_used_at INTEGER NOT NULL, revoked_at INTEGER
|
|
8
|
+
) STRICT;
|
|
9
|
+
CREATE TABLE auth_sessions (
|
|
10
|
+
id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES auth_devices(id),
|
|
11
|
+
secret_hash TEXT NOT NULL UNIQUE, origin TEXT NOT NULL,
|
|
12
|
+
transport TEXT NOT NULL CHECK(transport IN ('http', 'https')),
|
|
13
|
+
created_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL, revoked_at INTEGER
|
|
14
|
+
) STRICT;
|
|
15
|
+
CREATE INDEX auth_sessions_device ON auth_sessions(device_id);
|
|
16
|
+
CREATE INDEX auth_devices_expiry ON auth_devices(expires_at) WHERE revoked_at IS NULL;
|
|
17
|
+
CREATE TABLE auth_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
|
18
|
+
`;
|
|
2
19
|
const V1_SCHEMA = `
|
|
3
20
|
CREATE TABLE projects (
|
|
4
21
|
id TEXT PRIMARY KEY,
|
|
@@ -68,7 +85,7 @@ export function configureProjectDatabase(db) {
|
|
|
68
85
|
export function migrateProjectDatabase(db) {
|
|
69
86
|
const current = Number(pragmaValue(db, 'user_version') ?? 0);
|
|
70
87
|
if (current > PROJECT_TASK_SCHEMA_VERSION) {
|
|
71
|
-
throw new ProjectTaskError('PROJECT_STORE_TOO_NEW', 503, `Project data requires a newer
|
|
88
|
+
throw new ProjectTaskError('PROJECT_STORE_TOO_NEW', 503, `Project data requires a newer handmux version (schema ${current})`);
|
|
72
89
|
}
|
|
73
90
|
if (current === PROJECT_TASK_SCHEMA_VERSION)
|
|
74
91
|
return current;
|
|
@@ -76,6 +93,18 @@ export function migrateProjectDatabase(db) {
|
|
|
76
93
|
try {
|
|
77
94
|
if (current === 0)
|
|
78
95
|
db.exec(V1_SCHEMA);
|
|
96
|
+
if (current < 2) {
|
|
97
|
+
db.exec(V2_AUTH_SCHEMA);
|
|
98
|
+
// Public releases before trusted-device/auth-origin protection used schema 1 and
|
|
99
|
+
// allowed a valid Token from every known entry point. Preserve that behavior when
|
|
100
|
+
// upgrading an existing schema-1 database; a brand-new database (version 0) keeps
|
|
101
|
+
// the secure defaults applied by DeviceAuthService.
|
|
102
|
+
if (current === 1) {
|
|
103
|
+
db.exec("INSERT INTO auth_meta(key,value) VALUES('trusted_device_enabled','0'),('trusted_origin_enabled','0')");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (current < 3)
|
|
107
|
+
db.exec('ALTER TABLE auth_devices ADD COLUMN version INTEGER NOT NULL DEFAULT 1 CHECK(version >= 1)');
|
|
79
108
|
db.exec(`PRAGMA user_version = ${PROJECT_TASK_SCHEMA_VERSION}`);
|
|
80
109
|
db.exec('COMMIT');
|
|
81
110
|
}
|
|
@@ -150,11 +150,17 @@ export async function createProjectTaskRuntime({ home, databasePath = path.join(
|
|
|
150
150
|
}
|
|
151
151
|
return {
|
|
152
152
|
status: () => ({ ...state }),
|
|
153
|
+
requireDatabase() {
|
|
154
|
+
if (closed || !db || state.status !== 'ready')
|
|
155
|
+
throw new Error('Authentication database unavailable; restart handmux');
|
|
156
|
+
return db;
|
|
157
|
+
},
|
|
158
|
+
successfulWrite() { backups?.successfulWrite(); },
|
|
153
159
|
requireStore() {
|
|
154
160
|
if (closed || !store || state.status !== 'ready') {
|
|
155
161
|
const error = state.error ?? {
|
|
156
162
|
code: 'PROJECT_STORE_CORRUPT',
|
|
157
|
-
message: 'Project data is unavailable; restart
|
|
163
|
+
message: 'Project data is unavailable; restart handmux and try again',
|
|
158
164
|
};
|
|
159
165
|
throw new ProjectTaskError(error.code, error.code === 'PROJECT_STORE_FULL' ? 507 : 503, error.message);
|
|
160
166
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PROJECT_TASK_SCHEMA_VERSION =
|
|
1
|
+
export const PROJECT_TASK_SCHEMA_VERSION = 3;
|
|
2
2
|
export class ProjectTaskError extends Error {
|
|
3
3
|
code;
|
|
4
4
|
status;
|
|
@@ -30,7 +30,7 @@ export function projectStorageError(error) {
|
|
|
30
30
|
return new ProjectTaskError('PROJECT_STORE_LOCKED', 503, 'Project data is temporarily busy; close other processes using it and try again');
|
|
31
31
|
}
|
|
32
32
|
if (code === 'EACCES' || code === 'EPERM' || code === 'SQLITE_READONLY' || primary === 8) {
|
|
33
|
-
return new ProjectTaskError('PROJECT_STORE_PERMISSION', 503, '
|
|
33
|
+
return new ProjectTaskError('PROJECT_STORE_PERMISSION', 503, 'handmux cannot access Project data; fix ~/.handmux permissions and restart');
|
|
34
34
|
}
|
|
35
35
|
if (code === 'ENOSPC' || code === 'SQLITE_FULL' || primary === 13
|
|
36
36
|
|| detail.includes('database or disk is full')) {
|
package/dist/src/push.js
CHANGED
|
@@ -67,9 +67,28 @@ function parseStoredSubscription(value) {
|
|
|
67
67
|
if (wrapped && isRecord(value) && typeof value.pushKey === 'string' && VALID_PUSH_KEY.test(value.pushKey)) {
|
|
68
68
|
record.pushKey = value.pushKey;
|
|
69
69
|
}
|
|
70
|
+
if (wrapped && isRecord(value) && typeof value.authDeviceId === 'string')
|
|
71
|
+
record.authDeviceId = value.authDeviceId;
|
|
70
72
|
return record;
|
|
71
73
|
}
|
|
72
74
|
let subs = load();
|
|
75
|
+
let deviceAuthorization = null;
|
|
76
|
+
const allowed = (rec) => {
|
|
77
|
+
try {
|
|
78
|
+
return deviceAuthorization ? (!rec.authDeviceId || deviceAuthorization(rec.authDeviceId)) : !rec.authDeviceId;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
export function setDeviceAuthorization(check) { deviceAuthorization = check; }
|
|
85
|
+
export function revokeDevice(deviceId) {
|
|
86
|
+
const next = subs.filter(rec => rec.authDeviceId !== deviceId);
|
|
87
|
+
if (next.length !== subs.length) {
|
|
88
|
+
subs = next;
|
|
89
|
+
persist();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
73
92
|
function load() {
|
|
74
93
|
return readJsonArray(STORE)
|
|
75
94
|
.map(parseStoredSubscription)
|
|
@@ -78,44 +97,54 @@ function load() {
|
|
|
78
97
|
function persist() { writeJsonAtomic(STORE, subs); }
|
|
79
98
|
export function isConfigured() { ensureInit(); return configured; }
|
|
80
99
|
export function publicKey() { ensureInit(); return process.env.VAPID_PUBLIC || null; }
|
|
81
|
-
export function count() { return subs.length; }
|
|
82
|
-
export function addSubscription(sub, boundSessions = [], preferredPushKey = null) {
|
|
100
|
+
export function count() { return subs.filter(allowed).length; }
|
|
101
|
+
export function addSubscription(sub, boundSessions = [], preferredPushKey = null, authDeviceId) {
|
|
83
102
|
const parsed = parsePushSubscription(sub);
|
|
84
103
|
if (!parsed)
|
|
85
104
|
return false;
|
|
105
|
+
if (deviceAuthorization && (!authDeviceId || !deviceAuthorization(authDeviceId)))
|
|
106
|
+
return false;
|
|
86
107
|
const sessions = strings(boundSessions);
|
|
87
108
|
const endpointRecord = subs.find((s) => s.subscription.endpoint === parsed.endpoint);
|
|
109
|
+
if (endpointRecord && endpointRecord.authDeviceId !== authDeviceId && endpointRecord.authDeviceId && allowed(endpointRecord))
|
|
110
|
+
return false;
|
|
88
111
|
const requestedKey = typeof preferredPushKey === 'string' && VALID_PUSH_KEY.test(preferredPushKey) ? preferredPushKey : null;
|
|
89
|
-
const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey) : null;
|
|
90
|
-
const pushKey = keyRecord?.pushKey || endpointRecord?.pushKey
|
|
112
|
+
const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey && s.authDeviceId === authDeviceId) : null;
|
|
113
|
+
const pushKey = authDeviceId ? keyRecord?.pushKey || (endpointRecord?.authDeviceId === authDeviceId ? endpointRecord?.pushKey : null) || genKey()
|
|
114
|
+
: keyRecord?.pushKey || endpointRecord?.pushKey || requestedKey || genKey();
|
|
91
115
|
// A browser PushSubscription is transport state, not device identity. Re-enabling push can produce a
|
|
92
116
|
// different endpoint; replace the old endpoint that owns this stable key instead of creating a new
|
|
93
117
|
// script-push target. Also collapse any legacy duplicate for the new endpoint.
|
|
94
118
|
subs = subs.filter((s) => s.subscription.endpoint !== parsed.endpoint && s.pushKey !== pushKey);
|
|
95
|
-
subs.push({ subscription: parsed, boundSessions: sessions, pushKey });
|
|
119
|
+
subs.push({ subscription: parsed, boundSessions: sessions, pushKey, ...(authDeviceId ? { authDeviceId } : {}) });
|
|
96
120
|
persist();
|
|
97
121
|
return pushKey;
|
|
98
122
|
}
|
|
99
|
-
export function updateBound(endpoint, boundSessions = []) {
|
|
100
|
-
const rec = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
123
|
+
export function updateBound(endpoint, boundSessions = [], authDeviceId) {
|
|
124
|
+
const rec = subs.find((s) => s.subscription.endpoint === endpoint && s.authDeviceId === authDeviceId && allowed(s));
|
|
101
125
|
if (rec) {
|
|
102
126
|
rec.boundSessions = strings(boundSessions);
|
|
103
127
|
persist();
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
|
-
export function removeSubscription(endpoint) {
|
|
107
|
-
const removed = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
108
|
-
subs = subs.filter((s) => s
|
|
130
|
+
export function removeSubscription(endpoint, authDeviceId) {
|
|
131
|
+
const removed = subs.find((s) => s.subscription.endpoint === endpoint && s.authDeviceId === authDeviceId);
|
|
132
|
+
subs = subs.filter((s) => s !== removed);
|
|
109
133
|
if (removed)
|
|
110
134
|
persist();
|
|
111
135
|
return removed?.pushKey || null;
|
|
112
136
|
}
|
|
113
137
|
// The device-addressing id (NOT an auth credential — see /api/push/send-local). Lazy-generate for
|
|
114
138
|
// records stored before the feature existed so an already-subscribed device still has one.
|
|
115
|
-
export function getPushKey(endpoint) {
|
|
116
|
-
const rec = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
139
|
+
export function getPushKey(endpoint, authDeviceId) {
|
|
140
|
+
const rec = subs.find((s) => s.subscription.endpoint === endpoint && (s.authDeviceId === authDeviceId || (!s.authDeviceId && Boolean(authDeviceId))) && allowed(s));
|
|
117
141
|
if (!rec)
|
|
118
142
|
return null;
|
|
143
|
+
// Migrate subscriptions written by 0.28 on first authenticated use.
|
|
144
|
+
if (!rec.authDeviceId && authDeviceId) {
|
|
145
|
+
rec.authDeviceId = authDeviceId;
|
|
146
|
+
persist();
|
|
147
|
+
}
|
|
119
148
|
if (!rec.pushKey) {
|
|
120
149
|
rec.pushKey = genKey();
|
|
121
150
|
persist();
|
|
@@ -171,6 +200,8 @@ async function deliver(records, payload, opts = {}) {
|
|
|
171
200
|
let sent = 0;
|
|
172
201
|
let failed = 0;
|
|
173
202
|
await Promise.all(records.map(async (rec) => {
|
|
203
|
+
if (!allowed(rec))
|
|
204
|
+
return;
|
|
174
205
|
try {
|
|
175
206
|
await webpush.sendNotification(rec.subscription, data, options(opts));
|
|
176
207
|
sent += 1;
|
|
@@ -211,7 +242,7 @@ export const resolveTargetKeys = ({ devices, sessions } = {}) => {
|
|
|
211
242
|
: (sessions && sessions.length)
|
|
212
243
|
? subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x)))
|
|
213
244
|
: subs;
|
|
214
|
-
return [...new Set(pick.map((s) => s.pushKey).filter((key) => typeof key === 'string'))];
|
|
245
|
+
return [...new Set(pick.filter(allowed).map((s) => s.pushKey).filter((key) => typeof key === 'string'))];
|
|
215
246
|
};
|
|
216
247
|
// Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
|
|
217
248
|
export async function sendToOne(sub, payload, opts = {}) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
// Carry the HTTP authorization through awaited queues to the actual side-effect edge.
|
|
3
|
+
// No context means an internal/background operation; token mode remains unchanged.
|
|
4
|
+
const authority = new AsyncLocalStorage();
|
|
5
|
+
export function withRequestAuthority(check, operation) {
|
|
6
|
+
return authority.run(check, operation);
|
|
7
|
+
}
|
|
8
|
+
export function assertRequestAuthority() { authority.getStore()?.(); }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function sameHost(origin, hostHeader) {
|
|
2
|
+
try {
|
|
3
|
+
const requestHost = new URL(`http://${hostHeader}`);
|
|
4
|
+
const originPort = origin.port || (origin.protocol === 'https:' ? '443' : '80');
|
|
5
|
+
// The backend may receive a Host without a port after TLS termination. In
|
|
6
|
+
// that case, inherit the browser origin's default port; an explicit port
|
|
7
|
+
// must still match exactly (443 remains equivalent to an omitted port).
|
|
8
|
+
const explicitPort = hostHeader.match(/:(\d+)$/)?.[1];
|
|
9
|
+
const requestPort = explicitPort ?? originPort;
|
|
10
|
+
return origin.hostname === requestHost.hostname && originPort === requestPort;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function isLoopback(address) {
|
|
17
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
18
|
+
}
|
|
19
|
+
function browserProtocol(req, hostHeader) {
|
|
20
|
+
// Only a local TLS-terminating proxy may supply these implicit hints. For a
|
|
21
|
+
// direct remote request, an Origin header is not transport proof and must not
|
|
22
|
+
// turn an HTTP request into a trusted HTTPS origin.
|
|
23
|
+
if (!isLoopback(req.socket.remoteAddress))
|
|
24
|
+
return null;
|
|
25
|
+
const hinted = req.headers.origin;
|
|
26
|
+
if (typeof hinted === 'string' && hinted !== 'null') {
|
|
27
|
+
try {
|
|
28
|
+
const origin = new URL(hinted);
|
|
29
|
+
if (origin.pathname === '/' && !origin.username && !origin.password && !origin.search && !origin.hash
|
|
30
|
+
&& ['http:', 'https:'].includes(origin.protocol) && sameHost(origin, hostHeader)) {
|
|
31
|
+
return origin.protocol === 'https:' ? 'https' : 'http';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* fall through to the transport and cookie hints */ }
|
|
35
|
+
}
|
|
36
|
+
// TLS-terminating reverse proxies may omit X-Forwarded-Proto. A Secure handmux
|
|
37
|
+
// cookie is only sent by an HTTPS browser, so it provides a stable hint for the
|
|
38
|
+
// follow-up GET that usually omits Origin.
|
|
39
|
+
const cookie = req.headers.cookie;
|
|
40
|
+
if (typeof cookie === 'string' && /(?:^|;\s*)__Host-handmux_(?:session|pairing)(?:[_A-Za-z0-9-]*)?=/.test(cookie))
|
|
41
|
+
return 'https';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Parse the effective request origin without deciding whether it is allowed. */
|
|
45
|
+
export function requestOrigin(req) {
|
|
46
|
+
const encrypted = 'encrypted' in req.socket && Boolean(req.socket.encrypted);
|
|
47
|
+
const forwarded = req.headers['x-forwarded-proto'];
|
|
48
|
+
const forwardedHost = req.headers['x-forwarded-host'];
|
|
49
|
+
const hasForwardedHost = typeof forwardedHost === 'string' && forwardedHost.length > 0;
|
|
50
|
+
const hostHeader = hasForwardedHost
|
|
51
|
+
? (forwardedHost.split(',')[0] ?? '').trim() : req.headers.host;
|
|
52
|
+
if (!hostHeader || /[\s,/@\\?#]/.test(hostHeader))
|
|
53
|
+
return null;
|
|
54
|
+
const protocol = !encrypted && (forwarded === 'http' || forwarded === 'https')
|
|
55
|
+
? forwarded : encrypted ? 'https' : browserProtocol(req, hostHeader) ?? 'http';
|
|
56
|
+
try {
|
|
57
|
+
return new URL(`${protocol}://${hostHeader}`).origin;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -276,7 +276,9 @@ export function agentRoutes({ runtime }) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
const queue = runtime.conversation ? await runtime.conversation.queueSnapshot(lease) : undefined;
|
|
279
|
-
|
|
279
|
+
// The page and dispatcher consume the same Runtime activity snapshot.
|
|
280
|
+
return res.json({ controls: { ...controls,
|
|
281
|
+
...(queue ? { activity: queue.activity, queue, submissions: queue.submissions } : {}) } });
|
|
280
282
|
}
|
|
281
283
|
catch (error) {
|
|
282
284
|
if (controlError(error, res))
|
package/dist/src/routes/files.js
CHANGED
|
@@ -161,6 +161,7 @@ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
|
|
|
161
161
|
// Resolved up front for the response; re-resolved at link time if the race is lost (see below).
|
|
162
162
|
const origName = name;
|
|
163
163
|
let finalName = await freeUploadName(target.real, origName);
|
|
164
|
+
res.locals.assertDeviceActive?.();
|
|
164
165
|
let dest = joinPath(target.real, finalName);
|
|
165
166
|
const tempPath = joinPath(target.real, `.${finalName}.uploading-${randomBytes(6).toString('hex')}`);
|
|
166
167
|
tmp = tempPath;
|
|
@@ -181,6 +182,7 @@ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
|
|
|
181
182
|
// file, and a clash auto-suffixes rather than 409s.
|
|
182
183
|
let linked = false;
|
|
183
184
|
for (let attempt = 0; attempt < 6 && !linked; attempt++) {
|
|
185
|
+
res.locals.assertDeviceActive?.();
|
|
184
186
|
try {
|
|
185
187
|
await fsp.link(tempPath, dest);
|
|
186
188
|
linked = true;
|