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
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
import { tokenEquals } from '../auth.js';
|
|
3
|
+
export class DeviceAuthError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
status;
|
|
6
|
+
constructor(code, message, status = 400) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function parseExpire(value) {
|
|
13
|
+
if (value === 'never')
|
|
14
|
+
return null;
|
|
15
|
+
if (typeof value !== 'string' || !/^[1-9]\d*[mhd]$/.test(value))
|
|
16
|
+
throw new DeviceAuthError('INVALID_EXPIRE', 'Use a positive duration such as 1h, 7d, 30d, or never');
|
|
17
|
+
const unit = value.slice(-1);
|
|
18
|
+
const duration = Number(value.slice(0, -1)) * (unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000);
|
|
19
|
+
if (!Number.isSafeInteger(duration) || duration > 8_640_000_000_000_000 - Date.now())
|
|
20
|
+
throw new DeviceAuthError('INVALID_EXPIRE', 'Duration is too large');
|
|
21
|
+
return duration;
|
|
22
|
+
}
|
|
23
|
+
export function validateName(value) {
|
|
24
|
+
if (typeof value !== 'string' || !value.trim() || [...value.trim()].length > 80 || /[\x00-\x1f\x7f-\x9f]/.test(value))
|
|
25
|
+
throw new DeviceAuthError('INVALID_NAME', 'Device name must contain 1–80 characters without control characters');
|
|
26
|
+
return value.trim();
|
|
27
|
+
}
|
|
28
|
+
const hash = (secret) => createHash('sha256').update(secret).digest('hex');
|
|
29
|
+
function normalizeOrigin(value) {
|
|
30
|
+
if (typeof value !== 'string' || !value)
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
|
|
35
|
+
|| url.pathname !== '/' || url.search || url.hash)
|
|
36
|
+
return null;
|
|
37
|
+
return url.origin;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Normalize an additional trusted entry point. Wildcards are host-only and
|
|
44
|
+
* must be the left-most label (for example https://*.example.com). */
|
|
45
|
+
export function normalizeOriginPattern(value) {
|
|
46
|
+
if (typeof value !== 'string' || !value.trim())
|
|
47
|
+
return null;
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value.trim());
|
|
50
|
+
const host = url.hostname.toLowerCase();
|
|
51
|
+
const wildcard = host.startsWith('*.');
|
|
52
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
|
|
53
|
+
|| url.pathname !== '/' || url.search || url.hash || !host
|
|
54
|
+
|| (host.includes('*') && !wildcard) || (wildcard && !/^\*\.[^.]+(?:\.[^.]+)+$/.test(host)))
|
|
55
|
+
return null;
|
|
56
|
+
return url.origin;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function originPatternMatches(pattern, origin) {
|
|
63
|
+
try {
|
|
64
|
+
const expected = new URL(pattern);
|
|
65
|
+
const actual = new URL(origin);
|
|
66
|
+
if (expected.protocol !== actual.protocol || expected.port !== actual.port)
|
|
67
|
+
return false;
|
|
68
|
+
const host = expected.hostname.toLowerCase();
|
|
69
|
+
const candidate = actual.hostname.toLowerCase();
|
|
70
|
+
return host.startsWith('*.')
|
|
71
|
+
? candidate !== host.slice(2) && candidate.endsWith(`.${host.slice(2)}`)
|
|
72
|
+
: candidate === host;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function originPortSuffix(origin) {
|
|
79
|
+
try {
|
|
80
|
+
const port = new URL(origin).port;
|
|
81
|
+
return port ? `_${port}` : '';
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export const sessionCookieName = (origin) => origin.startsWith('https:') ? `__Host-handmux_session${originPortSuffix(origin)}` : `handmux_session_http${originPortSuffix(origin)}`;
|
|
88
|
+
export const pairingCookieName = (origin) => origin.startsWith('https:') ? `__Host-handmux_pairing${originPortSuffix(origin)}` : `handmux_pairing_http${originPortSuffix(origin)}`;
|
|
89
|
+
function readCookieSecret(req, name) {
|
|
90
|
+
const matches = (req.headers.cookie ?? '').split(';').map(v => v.trim()).filter(v => v.startsWith(`${name}=`));
|
|
91
|
+
if (matches.length !== 1)
|
|
92
|
+
return null;
|
|
93
|
+
const value = matches[0].slice(name.length + 1);
|
|
94
|
+
return /^[A-Za-z0-9_-]{43}$/.test(value) ? value : null;
|
|
95
|
+
}
|
|
96
|
+
export const readSessionSecret = (req, origin) => readCookieSecret(req, sessionCookieName(origin));
|
|
97
|
+
export function readPairingCookies(req, origin) {
|
|
98
|
+
const prefix = `${pairingCookieName(origin)}_`;
|
|
99
|
+
const names = (req.headers.cookie ?? '').split(';').map(v => v.trim().split('=')[0] ?? '')
|
|
100
|
+
.filter(name => name.startsWith(prefix) && /^[a-f0-9]{32}$/.test(name.slice(prefix.length)));
|
|
101
|
+
// The HTTP header itself is bounded by Node. Cap additional hash lookups independently.
|
|
102
|
+
return [...new Set(names)].slice(0, 32).flatMap(name => { const secret = readCookieSecret(req, name); return secret ? [{ name, secret }] : []; });
|
|
103
|
+
}
|
|
104
|
+
export class DeviceAuthService {
|
|
105
|
+
get mode() { return this.trustedDeviceEnabled ? 'trusted-device' : 'token'; }
|
|
106
|
+
get available() { return !this.closed; }
|
|
107
|
+
tokenSecret;
|
|
108
|
+
tokenGeneration;
|
|
109
|
+
trustedDeviceEnabledState;
|
|
110
|
+
trustedOriginEnabledState;
|
|
111
|
+
db;
|
|
112
|
+
now;
|
|
113
|
+
write;
|
|
114
|
+
pending = new Map();
|
|
115
|
+
retired = new Map();
|
|
116
|
+
listeners = new Set();
|
|
117
|
+
activity = new Map();
|
|
118
|
+
expireNotified = new Set();
|
|
119
|
+
observedDevices = new Set();
|
|
120
|
+
timer;
|
|
121
|
+
flushTimer;
|
|
122
|
+
claimFailures = new Map();
|
|
123
|
+
closed = false;
|
|
124
|
+
constructor({ db, token = '', now = Date.now, onSuccessfulWrite = () => { } }) {
|
|
125
|
+
this.db = db;
|
|
126
|
+
this.tokenSecret = token;
|
|
127
|
+
this.now = now;
|
|
128
|
+
this.write = onSuccessfulWrite;
|
|
129
|
+
const devicePolicyRow = db.prepare("SELECT value FROM auth_meta WHERE key='trusted_device_enabled'").get();
|
|
130
|
+
const originPolicyRow = db.prepare("SELECT value FROM auth_meta WHERE key='trusted_origin_enabled'").get();
|
|
131
|
+
const parseBoolean = (value) => value === '1' ? true : value === '0' ? false : undefined;
|
|
132
|
+
const storedDevicePolicy = parseBoolean(devicePolicyRow?.value);
|
|
133
|
+
const storedOriginPolicy = parseBoolean(originPolicyRow?.value);
|
|
134
|
+
// New installations start with both protections enabled. Explicit policy values
|
|
135
|
+
// are persisted so a CLI change survives a restart.
|
|
136
|
+
this.trustedDeviceEnabledState = storedDevicePolicy ?? true;
|
|
137
|
+
this.trustedOriginEnabledState = storedOriginPolicy ?? true;
|
|
138
|
+
const generation = db.prepare("SELECT value FROM auth_meta WHERE key='token_generation'").get();
|
|
139
|
+
this.tokenGeneration = generation?.value ?? randomUUID();
|
|
140
|
+
this.transaction(() => {
|
|
141
|
+
db.prepare("INSERT INTO auth_meta(key,value) VALUES('token_generation',?) ON CONFLICT(key) DO NOTHING").run(this.tokenGeneration);
|
|
142
|
+
db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_device_enabled',?) ON CONFLICT(key) DO NOTHING").run(this.trustedDeviceEnabledState ? '1' : '0');
|
|
143
|
+
db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origin_enabled',?) ON CONFLICT(key) DO NOTHING").run(this.trustedOriginEnabledState ? '1' : '0');
|
|
144
|
+
});
|
|
145
|
+
this.timer = setInterval(() => this.sweep(), 1000);
|
|
146
|
+
this.timer.unref();
|
|
147
|
+
this.flushTimer = setInterval(() => { try {
|
|
148
|
+
this.flush();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
console.error('[auth] Activity flush failed; will retry');
|
|
152
|
+
} }, 30_000);
|
|
153
|
+
this.flushTimer.unref();
|
|
154
|
+
}
|
|
155
|
+
/** Token is always the first authentication factor. */
|
|
156
|
+
get tokenEnabled() { return !this.closed; }
|
|
157
|
+
get trustedDeviceEnabled() { return !this.closed && this.trustedDeviceEnabledState; }
|
|
158
|
+
get trustedOriginEnabled() { return !this.closed && this.trustedOriginEnabledState; }
|
|
159
|
+
policy() {
|
|
160
|
+
return { trustedDeviceEnabled: this.trustedDeviceEnabled, trustedOriginEnabled: this.trustedOriginEnabled };
|
|
161
|
+
}
|
|
162
|
+
get requiresTrustedDevice() { return this.trustedDeviceEnabled && this.activeDeviceCount() === 0; }
|
|
163
|
+
get trustedOrigins() {
|
|
164
|
+
const row = this.db.prepare("SELECT value FROM auth_meta WHERE key='trusted_origins'").get();
|
|
165
|
+
if (!row?.value)
|
|
166
|
+
return [];
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(row.value);
|
|
169
|
+
if (!Array.isArray(parsed))
|
|
170
|
+
return [];
|
|
171
|
+
return [...new Set(parsed.map(normalizeOriginPattern).filter((v) => !!v))];
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
addTrustedOrigin(value) {
|
|
178
|
+
const normalized = normalizeOriginPattern(value);
|
|
179
|
+
if (!normalized)
|
|
180
|
+
throw new DeviceAuthError('INVALID_ORIGIN', 'Trusted access domain must be an origin such as https://*.example.com', 400);
|
|
181
|
+
const origins = this.trustedOrigins;
|
|
182
|
+
if (!origins.includes(normalized)) {
|
|
183
|
+
if (origins.length >= 64)
|
|
184
|
+
throw new DeviceAuthError('ORIGIN_LIMIT', 'Too many trusted access domains; remove one before adding another', 409);
|
|
185
|
+
origins.push(normalized);
|
|
186
|
+
this.transaction(() => this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(origins)));
|
|
187
|
+
}
|
|
188
|
+
return origins;
|
|
189
|
+
}
|
|
190
|
+
removeTrustedOrigin(value) {
|
|
191
|
+
const normalized = normalizeOriginPattern(value);
|
|
192
|
+
if (!normalized)
|
|
193
|
+
throw new DeviceAuthError('INVALID_ORIGIN', 'Trusted access domain must be an origin such as https://*.example.com', 400);
|
|
194
|
+
const origins = this.trustedOrigins.filter(origin => origin !== normalized);
|
|
195
|
+
this.transaction(() => {
|
|
196
|
+
this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(origins));
|
|
197
|
+
});
|
|
198
|
+
return origins;
|
|
199
|
+
}
|
|
200
|
+
inspectTrustedOriginRemoval(value) {
|
|
201
|
+
const normalized = normalizeOriginPattern(value);
|
|
202
|
+
if (!normalized)
|
|
203
|
+
throw new DeviceAuthError('INVALID_ORIGIN', 'Trusted access domain must be an origin', 400);
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const rows = this.db.prepare(`SELECT DISTINCT d.id, d.name, d.browser_summary, s.origin FROM auth_sessions s JOIN auth_devices d ON d.id=s.device_id WHERE s.revoked_at IS NULL AND d.revoked_at IS NULL AND (d.expires_at IS NULL OR d.expires_at > ?) AND s.origin IS NOT NULL`).all(now);
|
|
206
|
+
return rows.filter(row => originPatternMatches(normalized, row.origin)).map(({ id, name, browser_summary }) => ({ id, name, browser_summary }));
|
|
207
|
+
}
|
|
208
|
+
setTrustedOrigin(origin) {
|
|
209
|
+
let normalized;
|
|
210
|
+
try {
|
|
211
|
+
const url = new URL(origin);
|
|
212
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash)
|
|
213
|
+
throw new Error('invalid');
|
|
214
|
+
normalized = url.origin;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
throw new DeviceAuthError('INVALID_ORIGIN', 'Trusted access address must be a complete origin', 400);
|
|
218
|
+
}
|
|
219
|
+
const origins = this.trustedOrigins;
|
|
220
|
+
if (!origins.some(pattern => originPatternMatches(pattern, normalized))) {
|
|
221
|
+
if (origins.length >= 64)
|
|
222
|
+
throw new DeviceAuthError('ORIGIN_LIMIT', 'Too many trusted access domains; remove one before adding another', 409);
|
|
223
|
+
origins.push(normalized);
|
|
224
|
+
}
|
|
225
|
+
this.transaction(() => {
|
|
226
|
+
this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(origins));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
authenticateToken(provided, origin) {
|
|
230
|
+
if (!this.tokenEnabled || !this.tokenSecret || typeof provided !== 'string' || !provided || !tokenEquals(provided, this.tokenSecret))
|
|
231
|
+
return null;
|
|
232
|
+
return { deviceId: `token_${this.tokenGeneration}`, sessionId: `token_${this.tokenGeneration}`, expiresAt: null, origin };
|
|
233
|
+
}
|
|
234
|
+
isTokenPrincipal(principal) { return principal.deviceId.startsWith('token_'); }
|
|
235
|
+
setTrustedDeviceEnabled(enabled) {
|
|
236
|
+
this.requireMode();
|
|
237
|
+
this.transaction(() => this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_device_enabled',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(enabled ? '1' : '0'));
|
|
238
|
+
this.trustedDeviceEnabledState = enabled;
|
|
239
|
+
}
|
|
240
|
+
setTrustedOriginEnabled(enabled) {
|
|
241
|
+
this.requireMode();
|
|
242
|
+
this.transaction(() => this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origin_enabled',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(enabled ? '1' : '0'));
|
|
243
|
+
this.trustedOriginEnabledState = enabled;
|
|
244
|
+
}
|
|
245
|
+
transaction(run) {
|
|
246
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
247
|
+
let out;
|
|
248
|
+
try {
|
|
249
|
+
out = run();
|
|
250
|
+
this.db.exec('COMMIT');
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
try {
|
|
254
|
+
this.db.exec('ROLLBACK');
|
|
255
|
+
}
|
|
256
|
+
catch { }
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
// Backup scheduling is auxiliary; it must never turn an already committed authorization into a
|
|
260
|
+
// reported failure or leave its in-memory pairing state behind the durable transaction.
|
|
261
|
+
try {
|
|
262
|
+
this.write();
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
console.error('[auth] Backup scheduling failed');
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
requireMode() {
|
|
270
|
+
if (this.closed)
|
|
271
|
+
throw new DeviceAuthError('DEVICE_AUTH_DISABLED', 'Authentication is unavailable; restart handmux and retry', 409);
|
|
272
|
+
}
|
|
273
|
+
onRevoke(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
274
|
+
notify(id) {
|
|
275
|
+
for (const p of this.pending.values())
|
|
276
|
+
if (p.approver?.deviceId === id && p.state === 'configuring') {
|
|
277
|
+
p.state = 'canceled';
|
|
278
|
+
this.retire(p);
|
|
279
|
+
}
|
|
280
|
+
for (const listener of this.listeners) {
|
|
281
|
+
try {
|
|
282
|
+
listener(id);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
console.error('[auth] Capability cleanup failed');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
device(id) {
|
|
290
|
+
const row = this.db.prepare('SELECT id,name,browser_summary,authorized_at,expires_at,last_used_at,revoked_at,version FROM auth_devices WHERE id=?').get(id);
|
|
291
|
+
if (!row)
|
|
292
|
+
throw new DeviceAuthError('DEVICE_NOT_FOUND', 'Device ID not found; use handmux auth device list', 404);
|
|
293
|
+
return { ...row, status: row.revoked_at !== null ? 'revoked' : row.expires_at !== null && row.expires_at <= this.now() ? 'expired' : 'active' };
|
|
294
|
+
}
|
|
295
|
+
isDeviceActive(id) {
|
|
296
|
+
if (this.closed)
|
|
297
|
+
return false;
|
|
298
|
+
if (id.startsWith('token_')) {
|
|
299
|
+
// The Token is an authentication factor, not a row in auth_devices.
|
|
300
|
+
// Once trusted-device protection is enabled, a Token alone must no
|
|
301
|
+
// longer authorize an existing session or stream. The Token remains
|
|
302
|
+
// the first authentication factor for obtaining trusted-device access.
|
|
303
|
+
return this.tokenEnabled && !this.trustedDeviceEnabledState && id === `token_${this.tokenGeneration}`;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
return this.device(id).status === 'active';
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error instanceof DeviceAuthError)
|
|
310
|
+
return false;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
activeDeviceCount() {
|
|
315
|
+
return Number(this.db.prepare('SELECT COUNT(*) AS count FROM auth_devices WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)').get(this.now())?.count ?? 0);
|
|
316
|
+
}
|
|
317
|
+
isActive(principal) {
|
|
318
|
+
if (!this.isDeviceActive(principal.deviceId))
|
|
319
|
+
return false;
|
|
320
|
+
if (this.isTokenPrincipal(principal))
|
|
321
|
+
return principal.sessionId === principal.deviceId;
|
|
322
|
+
return !!this.db.prepare('SELECT id FROM auth_sessions WHERE id=? AND device_id=? AND revoked_at IS NULL').get(principal.sessionId, principal.deviceId);
|
|
323
|
+
}
|
|
324
|
+
authenticateRequest(req, origin) {
|
|
325
|
+
const secret = readSessionSecret(req, origin);
|
|
326
|
+
return secret ? this.authenticateSecret(secret, origin) : null;
|
|
327
|
+
}
|
|
328
|
+
authenticateSecret(secret, origin) {
|
|
329
|
+
if (this.closed)
|
|
330
|
+
return null;
|
|
331
|
+
const row = this.db.prepare(`SELECT s.id AS sessionId,s.device_id AS deviceId,d.expires_at AS expiresAt,s.origin
|
|
332
|
+
FROM auth_sessions s JOIN auth_devices d ON d.id=s.device_id WHERE s.secret_hash=? AND s.origin=?
|
|
333
|
+
AND s.transport=? AND s.revoked_at IS NULL AND d.revoked_at IS NULL AND (d.expires_at IS NULL OR d.expires_at>?)`).get(hash(secret), origin, origin.startsWith('https:') ? 'https' : 'http', this.now());
|
|
334
|
+
if (!row)
|
|
335
|
+
return null;
|
|
336
|
+
this.observedDevices.add(row.deviceId);
|
|
337
|
+
this.touch(row);
|
|
338
|
+
return row;
|
|
339
|
+
}
|
|
340
|
+
touch(principal) { if (!this.isTokenPrincipal(principal) && this.isActive(principal))
|
|
341
|
+
this.activity.set(principal.sessionId, { deviceId: principal.deviceId, at: this.now() }); }
|
|
342
|
+
flush() {
|
|
343
|
+
if (!this.activity.size)
|
|
344
|
+
return;
|
|
345
|
+
const entries = [...this.activity];
|
|
346
|
+
this.transaction(() => {
|
|
347
|
+
for (const [sessionId, { deviceId, at }] of entries) {
|
|
348
|
+
this.db.prepare('UPDATE auth_sessions SET last_used_at=MAX(last_used_at,?) WHERE id=?').run(at, sessionId);
|
|
349
|
+
this.db.prepare('UPDATE auth_devices SET last_used_at=MAX(last_used_at,?) WHERE id=?').run(at, deviceId);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
for (const [id, value] of entries)
|
|
353
|
+
if (this.activity.get(id) === value)
|
|
354
|
+
this.activity.delete(id);
|
|
355
|
+
}
|
|
356
|
+
list() {
|
|
357
|
+
this.requireMode();
|
|
358
|
+
const ids = this.db.prepare('SELECT id FROM auth_devices ORDER BY last_used_at DESC,id').all();
|
|
359
|
+
return ids.map(({ id }) => { const d = this.device(id); for (const a of this.activity.values())
|
|
360
|
+
if (a.deviceId === id)
|
|
361
|
+
d.last_used_at = Math.max(d.last_used_at, a.at); return d; });
|
|
362
|
+
}
|
|
363
|
+
assertActive(principal) {
|
|
364
|
+
if (!this.isActive(principal))
|
|
365
|
+
throw new DeviceAuthError('SESSION_INVALID', 'This device is no longer authorized; pair again', 401);
|
|
366
|
+
}
|
|
367
|
+
edit(id, values, actor) {
|
|
368
|
+
this.requireMode();
|
|
369
|
+
if (values.name === undefined && values.expire === undefined)
|
|
370
|
+
throw new DeviceAuthError('INVALID_EDIT', 'Provide --name or --expire');
|
|
371
|
+
const name = values.name === undefined ? undefined : validateName(values.name);
|
|
372
|
+
if (name !== undefined && this.db.prepare('SELECT id FROM auth_devices WHERE name=? AND id<>? AND revoked_at IS NULL').get(name, id))
|
|
373
|
+
throw new DeviceAuthError('DEVICE_NAME_TAKEN', 'Device name is already in use', 409);
|
|
374
|
+
const duration = values.expire === undefined ? undefined : parseExpire(values.expire);
|
|
375
|
+
return this.transaction(() => {
|
|
376
|
+
if (actor)
|
|
377
|
+
this.assertActive(actor);
|
|
378
|
+
const d = this.device(id);
|
|
379
|
+
if (actor && (!Number.isSafeInteger(values.version) || Number(values.version) < 1))
|
|
380
|
+
throw new DeviceAuthError('INVALID_VERSION', 'Refresh the device details before saving');
|
|
381
|
+
if (values.version !== undefined && values.version !== d.version)
|
|
382
|
+
throw new DeviceAuthError('DEVICE_CONFLICT', 'Device changed elsewhere; refresh the details and try again', 409);
|
|
383
|
+
if (d.status !== 'active')
|
|
384
|
+
throw new DeviceAuthError('DEVICE_INACTIVE', 'Expired or revoked devices must pair again', 409);
|
|
385
|
+
const expires = duration === undefined ? d.expires_at : duration === null ? null : this.now() + duration;
|
|
386
|
+
this.db.prepare('UPDATE auth_devices SET name=?,expires_at=?,version=version+1 WHERE id=?').run(name ?? d.name, expires, id);
|
|
387
|
+
return { ...this.device(id), previousExpiresAt: d.expires_at };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
revoke(id, actor) {
|
|
391
|
+
if (actor)
|
|
392
|
+
this.assertActive(actor);
|
|
393
|
+
this.requireMode();
|
|
394
|
+
const d = this.device(id);
|
|
395
|
+
if (d.revoked_at === null)
|
|
396
|
+
this.transaction(() => {
|
|
397
|
+
const now = this.now();
|
|
398
|
+
if (actor)
|
|
399
|
+
this.assertActive(actor);
|
|
400
|
+
this.db.prepare('UPDATE auth_devices SET revoked_at=?,version=version+1 WHERE id=?').run(now, id);
|
|
401
|
+
this.db.prepare('UPDATE auth_sessions SET revoked_at=? WHERE device_id=? AND revoked_at IS NULL').run(now, id);
|
|
402
|
+
});
|
|
403
|
+
this.notify(id);
|
|
404
|
+
return this.device(id);
|
|
405
|
+
}
|
|
406
|
+
retire(p) { this.retired.set(p.code, this.now() + 600_000); }
|
|
407
|
+
updatePair(p) {
|
|
408
|
+
if (p.state === 'configuring' && p.approver && !this.isActive(p.approver)) {
|
|
409
|
+
p.state = 'canceled';
|
|
410
|
+
this.retire(p);
|
|
411
|
+
}
|
|
412
|
+
if ((p.state === 'waiting' || p.state === 'configuring') && p.expiresAt <= this.now()) {
|
|
413
|
+
p.state = 'expired';
|
|
414
|
+
this.retire(p);
|
|
415
|
+
}
|
|
416
|
+
if (p.state === 'authorized' && p.deviceId && !this.isDeviceActive(p.deviceId)) {
|
|
417
|
+
p.state = this.device(p.deviceId).status === 'expired' ? 'expired' : 'canceled';
|
|
418
|
+
this.retire(p);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
view(p) { this.updatePair(p); return { id: p.id, state: p.state, ...(p.state === 'waiting' ? { code: p.code } : {}), expiresAt: p.expiresAt, origin: p.origin, ...(p.source ? { source: p.source } : {}) }; }
|
|
422
|
+
pairing(secret, origin) {
|
|
423
|
+
if (!secret)
|
|
424
|
+
return null;
|
|
425
|
+
const p = this.pending.get(hash(secret));
|
|
426
|
+
return p?.origin === origin ? this.view(p) : null;
|
|
427
|
+
}
|
|
428
|
+
createPairing(secret, origin, browserSummary) {
|
|
429
|
+
this.requireMode();
|
|
430
|
+
this.sweep();
|
|
431
|
+
const existing = secret ? this.pending.get(hash(secret)) : null;
|
|
432
|
+
if (existing?.origin === origin && (existing.state === 'waiting' || existing.state === 'configuring' || existing.state === 'authorized'))
|
|
433
|
+
return { pairing: this.view(existing) };
|
|
434
|
+
if (this.pending.size >= 256)
|
|
435
|
+
throw new DeviceAuthError('PAIRING_CAPACITY', 'Too many pending requests; wait a few minutes and try again', 429);
|
|
436
|
+
let code;
|
|
437
|
+
do {
|
|
438
|
+
code = String(randomInt(1_000_000)).padStart(6, '0');
|
|
439
|
+
} while (this.retired.has(code) || [...this.pending.values()].some(p => p.code === code));
|
|
440
|
+
const next = randomBytes(32).toString('base64url');
|
|
441
|
+
const p = { id: `pair_${randomUUID().replaceAll('-', '')}`, secretHash: hash(next), origin, code, state: 'waiting', expiresAt: this.now() + 60_000, browserSummary: browserSummary.replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 160) || 'Browser' };
|
|
442
|
+
if (existing) {
|
|
443
|
+
existing.state = 'canceled';
|
|
444
|
+
this.retire(existing);
|
|
445
|
+
this.pending.delete(existing.secretHash);
|
|
446
|
+
}
|
|
447
|
+
this.pending.set(p.secretHash, p);
|
|
448
|
+
return { pairing: this.view(p), secret: next };
|
|
449
|
+
}
|
|
450
|
+
registerSelf(secret, origin, values) {
|
|
451
|
+
this.requireMode();
|
|
452
|
+
const existing = this.authenticateSecret(secret, origin);
|
|
453
|
+
if (existing)
|
|
454
|
+
return this.device(existing.deviceId);
|
|
455
|
+
validateName(values.name);
|
|
456
|
+
parseExpire(values.expire);
|
|
457
|
+
const pairing = this.pending.get(hash(secret));
|
|
458
|
+
if (!pairing || pairing.origin !== origin)
|
|
459
|
+
throw new DeviceAuthError('PAIRING_NOT_FOUND', 'Prepare this browser for registration and retry', 409);
|
|
460
|
+
this.updatePair(pairing);
|
|
461
|
+
const owner = `self:${pairing.secretHash}`;
|
|
462
|
+
if (pairing.state === 'waiting')
|
|
463
|
+
this.claim(pairing.code, owner);
|
|
464
|
+
const device = this.authorize(pairing.id, owner, values);
|
|
465
|
+
return device;
|
|
466
|
+
}
|
|
467
|
+
cancelPairing(secret, origin, id) {
|
|
468
|
+
this.requireMode();
|
|
469
|
+
const p = secret ? this.pending.get(hash(secret)) : null;
|
|
470
|
+
if (!p || p.origin !== origin || p.id !== id)
|
|
471
|
+
throw new DeviceAuthError('PAIRING_NOT_FOUND', 'Pairing request changed; refresh this page', 409);
|
|
472
|
+
if (p.state !== 'authorized') {
|
|
473
|
+
p.state = 'canceled';
|
|
474
|
+
this.retire(p);
|
|
475
|
+
}
|
|
476
|
+
return this.view(p);
|
|
477
|
+
}
|
|
478
|
+
claim(code, owner, approver) {
|
|
479
|
+
this.requireMode();
|
|
480
|
+
this.sweep();
|
|
481
|
+
if (approver)
|
|
482
|
+
this.assertActive(approver);
|
|
483
|
+
for (const [key, failures] of this.claimFailures) {
|
|
484
|
+
const recent = failures.filter(at => at > this.now() - 60_000);
|
|
485
|
+
if (recent.length)
|
|
486
|
+
this.claimFailures.set(key, recent);
|
|
487
|
+
else
|
|
488
|
+
this.claimFailures.delete(key);
|
|
489
|
+
}
|
|
490
|
+
const failureKey = approver ? `web:${approver.sessionId}` : 'cli';
|
|
491
|
+
const failures = this.claimFailures.get(failureKey) ?? [];
|
|
492
|
+
if (failures.length >= 10 || (!this.claimFailures.has(failureKey) && this.claimFailures.size >= 1024))
|
|
493
|
+
throw new DeviceAuthError('CLAIM_RATE_LIMIT', 'Too many invalid codes; wait one minute before trying again', 429);
|
|
494
|
+
const p = typeof code === 'string' && /^\d{6}$/.test(code) ? [...this.pending.values()].find(p => p.code === code && p.state === 'waiting') : undefined;
|
|
495
|
+
if (!p) {
|
|
496
|
+
failures.push(this.now());
|
|
497
|
+
this.claimFailures.set(failureKey, failures);
|
|
498
|
+
throw new DeviceAuthError('CODE_INVALID', 'Code is invalid, expired, or already used; request a fresh code', 409);
|
|
499
|
+
}
|
|
500
|
+
p.state = 'configuring';
|
|
501
|
+
p.owner = owner;
|
|
502
|
+
p.source = approver ? 'web' : 'cli';
|
|
503
|
+
if (approver)
|
|
504
|
+
p.approver = approver;
|
|
505
|
+
p.expiresAt = this.now() + 300_000;
|
|
506
|
+
this.retire(p);
|
|
507
|
+
return { id: p.id, browserSummary: p.browserSummary, expiresAt: p.expiresAt, origin: p.origin };
|
|
508
|
+
}
|
|
509
|
+
cancelOwner(owner) { for (const p of this.pending.values())
|
|
510
|
+
if (p.owner === owner && p.state === 'configuring') {
|
|
511
|
+
p.state = 'canceled';
|
|
512
|
+
this.retire(p);
|
|
513
|
+
} }
|
|
514
|
+
approvalView(p) {
|
|
515
|
+
this.updatePair(p);
|
|
516
|
+
return { id: p.id, state: p.state, browserSummary: p.browserSummary, expiresAt: p.expiresAt, source: 'web', origin: p.origin, ...(p.state === 'authorized' && p.deviceId ? { device: this.device(p.deviceId) } : {}) };
|
|
517
|
+
}
|
|
518
|
+
approvals(actor) {
|
|
519
|
+
this.assertActive(actor);
|
|
520
|
+
return [...this.pending.values()].filter(p => p.source === 'web' && p.owner === `web:${actor.sessionId}`).map(p => this.approvalView(p));
|
|
521
|
+
}
|
|
522
|
+
approval(actor, id) {
|
|
523
|
+
const result = this.approvals(actor).find(p => p.id === id);
|
|
524
|
+
if (!result)
|
|
525
|
+
throw new DeviceAuthError('PAIRING_NOT_FOUND', 'This request is unavailable or belongs to another device', 404);
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
claimWeb(code, actor) {
|
|
529
|
+
this.assertActive(actor);
|
|
530
|
+
const owner = `web:${actor.sessionId}`;
|
|
531
|
+
const previous = [...this.pending.values()].find(p => p.owner === owner && p.code === code);
|
|
532
|
+
if (previous) {
|
|
533
|
+
this.updatePair(previous);
|
|
534
|
+
if (previous.state === 'configuring' || previous.state === 'authorized')
|
|
535
|
+
return this.approvalView(previous);
|
|
536
|
+
}
|
|
537
|
+
const claimed = this.claim(code, owner, actor);
|
|
538
|
+
return this.approval(actor, claimed.id);
|
|
539
|
+
}
|
|
540
|
+
cancelApproval(actor, id) {
|
|
541
|
+
this.approval(actor, id);
|
|
542
|
+
const p = [...this.pending.values()].find(p => p.id === id);
|
|
543
|
+
if (p.state === 'configuring') {
|
|
544
|
+
p.state = 'canceled';
|
|
545
|
+
this.retire(p);
|
|
546
|
+
}
|
|
547
|
+
return this.approvalView(p);
|
|
548
|
+
}
|
|
549
|
+
authorizeWeb(actor, id, values) {
|
|
550
|
+
this.assertActive(actor);
|
|
551
|
+
return this.authorize(id, `web:${actor.sessionId}`, values);
|
|
552
|
+
}
|
|
553
|
+
authorize(id, owner, values) {
|
|
554
|
+
this.requireMode();
|
|
555
|
+
const name = validateName(values.name);
|
|
556
|
+
const duration = parseExpire(values.expire);
|
|
557
|
+
if (this.db.prepare('SELECT id FROM auth_devices WHERE name=? AND revoked_at IS NULL').get(name))
|
|
558
|
+
throw new DeviceAuthError('DEVICE_NAME_TAKEN', 'Device name is already in use', 409);
|
|
559
|
+
const p = [...this.pending.values()].find(p => p.id === id && p.owner === owner);
|
|
560
|
+
if (!p)
|
|
561
|
+
throw new DeviceAuthError('PAIRING_NOT_FOUND', 'Pairing is no longer owned by this operation', 409);
|
|
562
|
+
this.updatePair(p);
|
|
563
|
+
if (p.state === 'authorized' && p.deviceId)
|
|
564
|
+
return this.device(p.deviceId);
|
|
565
|
+
if (p.state !== 'configuring')
|
|
566
|
+
throw new DeviceAuthError('PAIRING_INACTIVE', 'Pairing was canceled or expired; request a new code', 409);
|
|
567
|
+
const deviceId = `dev_${randomUUID().replaceAll('-', '')}`;
|
|
568
|
+
const now = this.now();
|
|
569
|
+
const pairingOrigin = normalizeOriginPattern(p.origin);
|
|
570
|
+
const currentOrigins = this.trustedOrigins;
|
|
571
|
+
const originAlreadyListed = pairingOrigin
|
|
572
|
+
? currentOrigins.some(pattern => originPatternMatches(pattern, pairingOrigin))
|
|
573
|
+
: false;
|
|
574
|
+
this.transaction(() => {
|
|
575
|
+
if (p.approver)
|
|
576
|
+
this.assertActive(p.approver);
|
|
577
|
+
this.db.prepare('INSERT INTO auth_devices(id,pairing_request_id,name,browser_summary,authorized_at,expires_at,last_used_at) VALUES(?,?,?,?,?,?,?)').run(deviceId, p.id, name, p.browserSummary, now, duration === null ? null : now + duration, now);
|
|
578
|
+
this.db.prepare('INSERT INTO auth_sessions(id,device_id,secret_hash,origin,transport,created_at,last_used_at) VALUES(?,?,?,?,?,?,?)').run(`ses_${randomUUID().replaceAll('-', '')}`, deviceId, p.secretHash, p.origin, p.origin.startsWith('https:') ? 'https' : 'http', now, now);
|
|
579
|
+
// Record the current access origin only after the pairing has been approved.
|
|
580
|
+
if (pairingOrigin && !originAlreadyListed) {
|
|
581
|
+
if (currentOrigins.length >= 64)
|
|
582
|
+
throw new DeviceAuthError('ORIGIN_LIMIT', 'Too many trusted access domains; remove one before adding another', 409);
|
|
583
|
+
currentOrigins.push(pairingOrigin);
|
|
584
|
+
this.db.prepare("INSERT INTO auth_meta(key,value) VALUES('trusted_origins',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(JSON.stringify(currentOrigins));
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
p.state = 'authorized';
|
|
588
|
+
p.deviceId = deviceId;
|
|
589
|
+
return this.device(deviceId);
|
|
590
|
+
}
|
|
591
|
+
sweep() {
|
|
592
|
+
try {
|
|
593
|
+
for (const [key, p] of this.pending) {
|
|
594
|
+
this.updatePair(p);
|
|
595
|
+
if (p.expiresAt + 600_000 < this.now())
|
|
596
|
+
this.pending.delete(key);
|
|
597
|
+
}
|
|
598
|
+
for (const [code, until] of this.retired)
|
|
599
|
+
if (until <= this.now())
|
|
600
|
+
this.retired.delete(code);
|
|
601
|
+
if (this.closed)
|
|
602
|
+
return;
|
|
603
|
+
const expired = this.db.prepare('SELECT id FROM auth_devices WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at<=?').all(this.now());
|
|
604
|
+
for (const { id } of expired)
|
|
605
|
+
if (!this.expireNotified.has(id)) {
|
|
606
|
+
this.expireNotified.add(id);
|
|
607
|
+
this.notify(id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// A storage fault must stop already-authorized streams as well as fail new requests closed.
|
|
612
|
+
for (const id of this.observedDevices)
|
|
613
|
+
this.notify(id);
|
|
614
|
+
this.observedDevices.clear();
|
|
615
|
+
this.notify(`token_${this.tokenGeneration}`);
|
|
616
|
+
console.error('[auth] Expiry check unavailable; device connections closed');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
close() { clearInterval(this.timer); clearInterval(this.flushTimer); try {
|
|
620
|
+
this.flush();
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
this.closed = true;
|
|
624
|
+
this.pending.clear();
|
|
625
|
+
} }
|
|
626
|
+
}
|
package/dist/src/docs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { assertRequestAuthority } from './requestAuthority.js';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { join, basename } from 'node:path';
|
|
4
5
|
import { TextDecoder } from 'node:util';
|
|
@@ -239,6 +240,7 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
|
|
|
239
240
|
async function resolveStashDir(rawCwd) {
|
|
240
241
|
const rh = await realHomeP;
|
|
241
242
|
const target = join(rh, '.handmux', 'uploads', encodeCwdKey(rawCwd));
|
|
243
|
+
assertRequestAuthority();
|
|
242
244
|
try {
|
|
243
245
|
await fs.mkdir(target, { recursive: true });
|
|
244
246
|
}
|
|
@@ -320,6 +322,7 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
|
|
|
320
322
|
if ('error' in parent)
|
|
321
323
|
return parent;
|
|
322
324
|
const target = join(parent.real, nm);
|
|
325
|
+
assertRequestAuthority();
|
|
323
326
|
try {
|
|
324
327
|
await fs.mkdir(target);
|
|
325
328
|
}
|