mouaif 0.3.0 → 0.3.2
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 +1 -1
- package/bin/mouaif.js +1 -5
- package/frontend/dist/assets/{AgentFilePicker-CcKLJorU.js → AgentFilePicker-ZW6vm5Hy.js} +1 -1
- package/frontend/dist/assets/{CliModal-Hs5phmNZ.js → CliModal-D2cX4W0a.js} +1 -1
- package/frontend/dist/assets/{DictationPage-BI23lp42.js → DictationPage-Dwb6gGNi.js} +1 -1
- package/frontend/dist/assets/{FileEditor-DDl31c6d.js → FileEditor-BqgbeJHy.js} +1 -1
- package/frontend/dist/assets/{GitModal-3EC_gpJ5.js → GitModal-BPaOnX1a.js} +1 -1
- package/frontend/dist/assets/{Inspector-Ba3R1w04.js → Inspector-C88hoEcC.js} +1 -1
- package/frontend/dist/assets/{SettingsAbout-bvZGDEDw.js → SettingsAbout-D5gHujOy.js} +1 -1
- package/frontend/dist/assets/{SettingsActions-Dk6WX9jv.js → SettingsActions-YrcVF8D3.js} +1 -1
- package/frontend/dist/assets/{SettingsAgents-BNV0MgDB.js → SettingsAgents-BVwBxPbO.js} +1 -1
- package/frontend/dist/assets/{SettingsDefaults-DbMmQbzc.js → SettingsDefaults-CMSIeSt8.js} +1 -1
- package/frontend/dist/assets/{SettingsHiddenContent-BZ2sloH1.js → SettingsHiddenContent-XE1HiHzN.js} +1 -1
- package/frontend/dist/assets/{SettingsMcp-DOrfbQd1.js → SettingsMcp-BAK8U0e4.js} +1 -1
- package/frontend/dist/assets/{SettingsMcpEdit-BGMQ2CWC.js → SettingsMcpEdit-j21BWCAH.js} +1 -1
- package/frontend/dist/assets/{SettingsMcpRegistry-BywXee_A.js → SettingsMcpRegistry-DH5YAigF.js} +1 -1
- package/frontend/dist/assets/{SettingsNotifications-B0LEs11a.js → SettingsNotifications-DJt0f8Jn.js} +1 -1
- package/frontend/dist/assets/{SettingsPricing-BAg33iVF.js → SettingsPricing-Csy5MjG-.js} +1 -1
- package/frontend/dist/assets/{SettingsProject-DNrKhCcZ.js → SettingsProject-Cry5UltF.js} +1 -1
- package/frontend/dist/assets/{SettingsProjects-IqkBfDcm.js → SettingsProjects-d3wgiADv.js} +1 -1
- package/frontend/dist/assets/{SettingsPrompts-BgeiASuk.js → SettingsPrompts-B9ECDKEa.js} +1 -1
- package/frontend/dist/assets/{SettingsProviders-k0xJN0IK.js → SettingsProviders-BalA35c9.js} +1 -1
- package/frontend/dist/assets/{SettingsTags-B5kjFdQi.js → SettingsTags-D_1AgCwY.js} +1 -1
- package/frontend/dist/assets/index-BDkCsON6.js +61 -0
- package/frontend/dist/assets/index-FzhcinOk.css +1 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +1 -1
- package/package.json +6 -7
- package/src/access-auth.js +1 -86
- package/src/http-server.js +2 -9
- package/src/pty.js +189 -0
- package/src/server-handlers-access.js +1 -101
- package/src/server-handlers-tools.js +25 -36
- package/src/server-shared.js +1 -14
- package/src/statusBar.js +19 -8
- package/frontend/dist/assets/index-BGvI4n0T.js +0 -61
- package/frontend/dist/assets/index-CANPYzQg.css +0 -1
package/src/access-auth.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Local app access authentication: one account, password sessions, one-time
|
|
4
|
-
// setup codes,
|
|
5
|
-
// OAuth remains in auth.js.
|
|
4
|
+
// setup codes, and WebAuthn credentials. Provider OAuth remains in auth.js.
|
|
6
5
|
|
|
7
6
|
const crypto = require('node:crypto');
|
|
8
7
|
const settings = require('./settings.js');
|
|
9
8
|
|
|
10
9
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
10
|
const SETUP_TTL_MS = 15 * 60 * 1000;
|
|
12
|
-
const DISABLE_TTL_MS = 15 * 60 * 1000;
|
|
13
11
|
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
14
12
|
const CODE_ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
|
15
13
|
const challenges = new Map();
|
|
@@ -63,16 +61,6 @@ function ensureTables() {
|
|
|
63
61
|
created_at TEXT NOT NULL,
|
|
64
62
|
last_used_at TEXT
|
|
65
63
|
);
|
|
66
|
-
CREATE TABLE IF NOT EXISTS access_disable_codes (
|
|
67
|
-
code_hash TEXT PRIMARY KEY,
|
|
68
|
-
expires_at INTEGER NOT NULL,
|
|
69
|
-
created_at TEXT NOT NULL
|
|
70
|
-
);
|
|
71
|
-
CREATE TABLE IF NOT EXISTS access_state (
|
|
72
|
-
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
73
|
-
enabled INTEGER NOT NULL,
|
|
74
|
-
updated_at TEXT NOT NULL
|
|
75
|
-
);
|
|
76
64
|
`);
|
|
77
65
|
}
|
|
78
66
|
|
|
@@ -85,35 +73,6 @@ function configured() {
|
|
|
85
73
|
return !!user();
|
|
86
74
|
}
|
|
87
75
|
|
|
88
|
-
// ---- Enable state -------------------------------------------------------
|
|
89
|
-
// `mouaif serve --auth` (and friends) turns the gate on for a process, but
|
|
90
|
-
// access can also be disabled from inside the app — turning a protection off
|
|
91
|
-
// usually means "right now, on this device". The decision is persisted so a
|
|
92
|
-
// later restart that still passes an auth flag keeps it off instead of
|
|
93
|
-
// silently locking the user out of a store whose password may be forgotten.
|
|
94
|
-
function disabled() {
|
|
95
|
-
ensureTables();
|
|
96
|
-
const row = settings.getDb().prepare('SELECT enabled FROM access_state WHERE id = 1').get();
|
|
97
|
-
return !!row && row.enabled === 0;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function setEnabled(enabled) {
|
|
101
|
-
ensureTables();
|
|
102
|
-
const on = enabled !== false;
|
|
103
|
-
settings.getDb().prepare(`
|
|
104
|
-
INSERT INTO access_state (id, enabled, updated_at) VALUES (1, ?, ?)
|
|
105
|
-
ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, updated_at = excluded.updated_at
|
|
106
|
-
`).run(on ? 1 : 0, new Date().toISOString());
|
|
107
|
-
return on;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// effectiveEnabled(processEnabled) — the gate the server actually enforces:
|
|
111
|
-
// a process started with an auth flag, unless the store says the user
|
|
112
|
-
// disabled access from inside the app.
|
|
113
|
-
function effectiveEnabled(processEnabled) {
|
|
114
|
-
return !!processEnabled && !disabled();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
76
|
function validateCredentials(username, password) {
|
|
118
77
|
const cleanUser = String(username || '').trim();
|
|
119
78
|
const cleanPassword = String(password || '');
|
|
@@ -260,43 +219,6 @@ function consumeSetupCode(code) {
|
|
|
260
219
|
return transaction();
|
|
261
220
|
}
|
|
262
221
|
|
|
263
|
-
// createDisableCode() — mint a short-lived, single-use code that turns app
|
|
264
|
-
// access off. Modelled on createSetupCode() with its own table so the two
|
|
265
|
-
// invitations can never be confused (a setup code adds a password, a disable
|
|
266
|
-
// code removes the login wall). Restricting access is signed-in-only, so no
|
|
267
|
-
// code is needed for that direction; only disabling is code-gated.
|
|
268
|
-
function createDisableCode(ttlMs = DISABLE_TTL_MS) {
|
|
269
|
-
ensureTables();
|
|
270
|
-
const code = randomCode();
|
|
271
|
-
const expiresAt = Date.now() + Math.max(60_000, Number(ttlMs) || DISABLE_TTL_MS);
|
|
272
|
-
settings.getDb().prepare('DELETE FROM access_disable_codes WHERE expires_at <= ?').run(Date.now());
|
|
273
|
-
settings.getDb().prepare('INSERT INTO access_disable_codes (code_hash, expires_at, created_at) VALUES (?, ?, ?)')
|
|
274
|
-
.run(digest(code), expiresAt, new Date().toISOString());
|
|
275
|
-
return { code, expiresAt };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function disableCodeValid(code) {
|
|
279
|
-
ensureTables();
|
|
280
|
-
const normalized = normalizeCode(code);
|
|
281
|
-
if (!normalized) return false;
|
|
282
|
-
return !!settings.getDb().prepare('SELECT 1 FROM access_disable_codes WHERE code_hash = ? AND expires_at > ?')
|
|
283
|
-
.get(digest(normalized), Date.now());
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function consumeDisableCode(code) {
|
|
287
|
-
ensureTables();
|
|
288
|
-
const normalized = normalizeCode(code);
|
|
289
|
-
if (!normalized) return false;
|
|
290
|
-
const hash = digest(normalized);
|
|
291
|
-
const transaction = settings.getDb().transaction(() => {
|
|
292
|
-
const valid = settings.getDb().prepare('SELECT 1 FROM access_disable_codes WHERE code_hash = ? AND expires_at > ?').get(hash, Date.now());
|
|
293
|
-
if (!valid) return false;
|
|
294
|
-
settings.getDb().prepare('DELETE FROM access_disable_codes WHERE code_hash = ?').run(hash);
|
|
295
|
-
return true;
|
|
296
|
-
});
|
|
297
|
-
return transaction();
|
|
298
|
-
}
|
|
299
|
-
|
|
300
222
|
function passkeys() {
|
|
301
223
|
ensureTables();
|
|
302
224
|
return settings.getDb().prepare('SELECT credential_id AS id, name, created_at AS createdAt, last_used_at AS lastUsedAt FROM access_passkeys ORDER BY created_at').all();
|
|
@@ -486,13 +408,9 @@ function deletePasskey(id) {
|
|
|
486
408
|
module.exports = {
|
|
487
409
|
SESSION_TTL_MS,
|
|
488
410
|
SETUP_TTL_MS,
|
|
489
|
-
DISABLE_TTL_MS,
|
|
490
411
|
ensureTables,
|
|
491
412
|
configured,
|
|
492
413
|
user,
|
|
493
|
-
disabled,
|
|
494
|
-
setEnabled,
|
|
495
|
-
effectiveEnabled,
|
|
496
414
|
setPassword,
|
|
497
415
|
verifyPassword,
|
|
498
416
|
changePassword,
|
|
@@ -503,9 +421,6 @@ module.exports = {
|
|
|
503
421
|
normalizeCode,
|
|
504
422
|
setupCodeValid,
|
|
505
423
|
consumeSetupCode,
|
|
506
|
-
createDisableCode,
|
|
507
|
-
disableCodeValid,
|
|
508
|
-
consumeDisableCode,
|
|
509
424
|
passkeys,
|
|
510
425
|
beginRegistration,
|
|
511
426
|
finishRegistration,
|
package/src/http-server.js
CHANGED
|
@@ -454,14 +454,7 @@ function createServer(port = DEFAULT_PORT, options = {}) {
|
|
|
454
454
|
}
|
|
455
455
|
const sessionToken = crypto.randomBytes(32).toString('base64url');
|
|
456
456
|
const lifecycle = (options && typeof options === 'object') ? (options.lifecycle || {}) : {};
|
|
457
|
-
const serverConfig = {
|
|
458
|
-
publicOrigin,
|
|
459
|
-
// The flag the process was started with. The store's in-app disable
|
|
460
|
-
// switch is layered on top at every gate (publicAccessStatus /
|
|
461
|
-
// authorizeAccessRequest) so turning access off or back on takes effect
|
|
462
|
-
// without a restart.
|
|
463
|
-
authEnabled: options.authEnabled === true
|
|
464
|
-
};
|
|
457
|
+
const serverConfig = { publicOrigin, authEnabled: options.authEnabled === true };
|
|
465
458
|
const server = http.createServer((req, res) => {
|
|
466
459
|
// Bind port to the request handler
|
|
467
460
|
handleRequest(req, res, port, sessionToken, lifecycle, serverConfig);
|
|
@@ -485,7 +478,7 @@ function createServer(port = DEFAULT_PORT, options = {}) {
|
|
|
485
478
|
const validToken = actual.length === sessionToken.length
|
|
486
479
|
&& crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(sessionToken));
|
|
487
480
|
const accessToken = cookies[ACCESS_COOKIE] || '';
|
|
488
|
-
const validAccess = !serverConfig.authEnabled ||
|
|
481
|
+
const validAccess = !serverConfig.authEnabled || !!accessAuth.session(accessToken);
|
|
489
482
|
if (!origin || origin !== expected || !validToken || !validAccess) {
|
|
490
483
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
491
484
|
socket.end();
|
package/src/pty.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A dependency-free pseudo-terminal shim.
|
|
4
|
+
//
|
|
5
|
+
// The CLI modal's persistent session (src/server-handlers-tools.js) wants a
|
|
6
|
+
// child whose stdin is a TTY, because programs that ask a question — `read`,
|
|
7
|
+
// `npm publish` under 2FA, `sudo`, git credential prompts — either get an
|
|
8
|
+
// immediate EOF or refuse to prompt when stdin is a pipe.
|
|
9
|
+
//
|
|
10
|
+
// This module used to be delegated to `node-pty`. That native addon ships no
|
|
11
|
+
// prebuilt binary for Linux (only darwin/win32), so a plain `npm install`
|
|
12
|
+
// without a C++ toolchain failed outright on Linux — the exact platform where
|
|
13
|
+
// mouaif is most often installed (Docker, CI, Codespaces, VPS). Instead of a
|
|
14
|
+
// replacement dependency this module allocates the TTY with `script(1)` from
|
|
15
|
+
// util-linux, present on every Linux base image including
|
|
16
|
+
// `node:22-bookworm-slim`, and falls back to a piped child where it is not.
|
|
17
|
+
//
|
|
18
|
+
// `spawnPty(exe, args, opts)` returns a handle shaped like the handful of
|
|
19
|
+
// node-pty members the session uses — `onData`, `onExit`, `write`, `kill`,
|
|
20
|
+
// `pid`, `killed` — or `null` when no TTY can be allocated, in which case the
|
|
21
|
+
// caller keeps its piped `child_process.spawn` path.
|
|
22
|
+
//
|
|
23
|
+
// How the TTY is obtained: `script -qefc "<command>" /dev/null` runs `printf`
|
|
24
|
+
// (the caveat in the original node-pty code) on a pty slave and copies the
|
|
25
|
+
// master to its own stdout, which is merged onto the pty by the kernel. So the
|
|
26
|
+
// shell and everything it starts see a terminal, while the server keeps a plain
|
|
27
|
+
// pipe to `script` — one readable stream, like node-pty's merged stdout.
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
32
|
+
|
|
33
|
+
// The `script` implementation we can rely on: util-linux, which supports
|
|
34
|
+
// `-e` (return the child's exit code), `-f` (flush) and `-c` (command). BSD /
|
|
35
|
+
// macOS `script` shares the name but not the flags, and Windows has no
|
|
36
|
+
// equivalent, so on those platforms the caller's piped path stays in charge
|
|
37
|
+
// (the documented degraded mode) and `isAvailable()` reports false.
|
|
38
|
+
const SUPPORTED_PLATFORM = process.platform === 'linux';
|
|
39
|
+
|
|
40
|
+
let resolved;
|
|
41
|
+
let probeDone = false;
|
|
42
|
+
let scriptBin = null;
|
|
43
|
+
|
|
44
|
+
// Resolve an executable file on PATH without spawning anything, so the probe
|
|
45
|
+
// below only runs when `script` is actually present.
|
|
46
|
+
function which(name) {
|
|
47
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
48
|
+
for (const dir of dirs) {
|
|
49
|
+
const candidate = path.join(dir, name);
|
|
50
|
+
try {
|
|
51
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
52
|
+
if (fs.statSync(candidate).isFile()) return candidate;
|
|
53
|
+
} catch { /* not here; keep looking */ }
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// One-shot capability probe: confirm this `script` accepts our flags before a
|
|
59
|
+
// session depends on it. A util-linux build that predates `-f`, or a foreign
|
|
60
|
+
// `script` that happens to be on PATH, fails the probe and the caller keeps the
|
|
61
|
+
// piped fallback instead of a session that dies on spawn.
|
|
62
|
+
function probeScript(bin) {
|
|
63
|
+
const result = spawnSync(bin, ['-qefc', 'exit 0', '/dev/null'], {
|
|
64
|
+
stdio: 'ignore',
|
|
65
|
+
timeout: 2000,
|
|
66
|
+
windowsHide: true
|
|
67
|
+
});
|
|
68
|
+
return !result.error && result.status === 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveScript() {
|
|
72
|
+
if (probeDone) return scriptBin;
|
|
73
|
+
probeDone = true;
|
|
74
|
+
if (!SUPPORTED_PLATFORM) return (scriptBin = null);
|
|
75
|
+
const bin = which('script');
|
|
76
|
+
scriptBin = bin && probeScript(bin) ? bin : null;
|
|
77
|
+
return scriptBin;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// True when a real pseudo-terminal can be allocated on this host. The CLI
|
|
81
|
+
// session reports this as `interactive`, and the modal hides its prompt badge
|
|
82
|
+
// when it is false.
|
|
83
|
+
function isAvailable() {
|
|
84
|
+
return !!resolveScript();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The command string `script -c` hands to /bin/sh, so every word is quoted for
|
|
88
|
+
// that shell: a project path, shell path or argument containing a space or
|
|
89
|
+
// quote must survive the round trip.
|
|
90
|
+
function shellQuote(word) {
|
|
91
|
+
return "'" + String(word).replace(/'/g, "'\\''") + "'";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shellCommand(exe, args) {
|
|
95
|
+
return [exe].concat(args || []).map(shellQuote).join(' ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `kill()` has to reach the whole tree: the shell spawned its own children
|
|
99
|
+
// (a running `npm test`, a pager), and `script` sits in between. POSIX only:
|
|
100
|
+
// detached:true puts `script` in its own process group, so signalling the
|
|
101
|
+
// negative pid takes down the shell and everything under it.
|
|
102
|
+
function terminate(child) {
|
|
103
|
+
if (!child || child.killed) return;
|
|
104
|
+
if (process.platform !== 'win32' && child.pid) {
|
|
105
|
+
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* group already gone */ }
|
|
106
|
+
}
|
|
107
|
+
try { child.kill(); } catch { /* already reaped */ }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function spawnPty(exe, args, opts) {
|
|
111
|
+
const bin = resolveScript();
|
|
112
|
+
if (!bin) return null;
|
|
113
|
+
const options = opts || {};
|
|
114
|
+
const child = spawn(bin, ['-qefc', shellCommand(exe, args), '/dev/null'], {
|
|
115
|
+
cwd: options.cwd,
|
|
116
|
+
env: options.env || process.env,
|
|
117
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
118
|
+
windowsHide: true,
|
|
119
|
+
detached: process.platform !== 'win32'
|
|
120
|
+
});
|
|
121
|
+
if (!child || !child.pid) return null;
|
|
122
|
+
|
|
123
|
+
const dataHandlers = [];
|
|
124
|
+
const exitHandlers = [];
|
|
125
|
+
// A PTY is not rewindable: anything written before the caller attaches its
|
|
126
|
+
// stream (the shell's first prompt, a login banner) would be lost. Hold a
|
|
127
|
+
// bounded amount until the first `onData` arrives, then flush it in order.
|
|
128
|
+
let pending = [];
|
|
129
|
+
let pendingBytes = 0;
|
|
130
|
+
const PENDING_LIMIT = 65536;
|
|
131
|
+
let killed = false;
|
|
132
|
+
|
|
133
|
+
const emit = (chunk) => {
|
|
134
|
+
if (dataHandlers.length) {
|
|
135
|
+
for (const handler of dataHandlers) {
|
|
136
|
+
try { handler(chunk); } catch { /* a bad handler must not break the stream */ }
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
pendingBytes += chunk.length;
|
|
141
|
+
pending.push(chunk);
|
|
142
|
+
while (pendingBytes > PENDING_LIMIT && pending.length > 1) {
|
|
143
|
+
pendingBytes -= pending.shift().length;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// A PTY merges stdout and stderr, so both feeds report on the single `data`
|
|
148
|
+
// stream; `-e` keeps the child's exit code, surfaced through `onExit` the
|
|
149
|
+
// way node-pty reported it.
|
|
150
|
+
child.stdout.on('data', emit);
|
|
151
|
+
child.stderr.on('data', emit);
|
|
152
|
+
child.on('exit', (code, signal) => {
|
|
153
|
+
killed = true;
|
|
154
|
+
const event = { exitCode: code == null ? 0 : code, signal: signal ? 1 : 0 };
|
|
155
|
+
for (const handler of exitHandlers) {
|
|
156
|
+
try { handler(event); } catch { /* ignore */ }
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
// node-pty compatibility surface used by the CLI session.
|
|
162
|
+
pty: true,
|
|
163
|
+
pid: child.pid,
|
|
164
|
+
get killed() { return killed || child.killed; },
|
|
165
|
+
onData(handler) {
|
|
166
|
+
if (typeof handler !== 'function') return;
|
|
167
|
+
dataHandlers.push(handler);
|
|
168
|
+
if (pending.length) {
|
|
169
|
+
const buffered = pending;
|
|
170
|
+
pending = [];
|
|
171
|
+
pendingBytes = 0;
|
|
172
|
+
for (const chunk of buffered) handler(chunk);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
onExit(handler) {
|
|
176
|
+
if (typeof handler === 'function') exitHandlers.push(handler);
|
|
177
|
+
},
|
|
178
|
+
write(data) {
|
|
179
|
+
if (!child.stdin || !child.stdin.writable) return false;
|
|
180
|
+
try { return child.stdin.write(String(data)); } catch { return false; }
|
|
181
|
+
},
|
|
182
|
+
kill() {
|
|
183
|
+
killed = true;
|
|
184
|
+
terminate(child);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = { isAvailable, spawnPty };
|
|
@@ -51,28 +51,6 @@ actions: [{ action: 'open', title: 'Open mouaif' }]
|
|
|
51
51
|
} catch { /* a notification failure must never block sign-in */ }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// notifyAccessChange(enabled) — best-effort push that app access protection
|
|
55
|
-
// changed. A security-relevant event, so it reuses the `login` channel
|
|
56
|
-
// ("sign in to / access of this server") rather than adding a preference;
|
|
57
|
-
// it broadcasts to every subscribed device, including the one that acted,
|
|
58
|
-
// so the state change is visible even if that phone is put down.
|
|
59
|
-
function notifyAccessChange(enabled) {
|
|
60
|
-
try {
|
|
61
|
-
let prefs = {};
|
|
62
|
-
try { prefs = resolveNotificationPrefs((settings.getApp() || {}).notifications); } catch { /* defaults apply */ }
|
|
63
|
-
if (prefs.login !== true) return;
|
|
64
|
-
push.sendPushToAll({
|
|
65
|
-
title: enabled ? 'mouaif access enabled' : 'mouaif access disabled',
|
|
66
|
-
body: enabled
|
|
67
|
-
? 'Password and passkey protection is on again.'
|
|
68
|
-
: 'App access is off. Anyone who can reach this server can open it.',
|
|
69
|
-
tag: 'mouaif-access',
|
|
70
|
-
data: { kind: 'login', url: '/#/settings/access' },
|
|
71
|
-
actions: [{ action: 'open', title: 'Open settings' }]
|
|
72
|
-
});
|
|
73
|
-
} catch { /* a notification failure must never change the access state */ }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
54
|
|
|
77
55
|
async function handleAccess(req, res, parsed, serverConfig) {
|
|
78
56
|
const urlPath = parsed.pathname;
|
|
@@ -85,8 +63,7 @@ async function handleAccess(req, res, parsed, serverConfig) {
|
|
|
85
63
|
const activeSession = accessAuth.session(accessToken);
|
|
86
64
|
|
|
87
65
|
if (urlPath === '/api/access/status' && method === 'GET') {
|
|
88
|
-
|
|
89
|
-
return sendJSON(res, 200, { ...status, session: !!activeSession, authenticated: !status.enabled || !!activeSession });
|
|
66
|
+
return sendJSON(res, 200, { ...publicAccessStatus(serverConfig.authEnabled), authenticated: !serverConfig.authEnabled || !!activeSession });
|
|
90
67
|
}
|
|
91
68
|
|
|
92
69
|
if (urlPath === '/api/access/login' && method === 'POST') {
|
|
@@ -112,83 +89,6 @@ return sendJSON(res, 200, { ok: true, user: accessAuth.user().username });
|
|
|
112
89
|
return sendJSON(res, 200, { ok: true });
|
|
113
90
|
}
|
|
114
91
|
|
|
115
|
-
// ---- Turn app access off / back on ------------------------------------
|
|
116
|
-
// Disabling is signed-in-only and requires a fresh one-time code, so a
|
|
117
|
-
// request that only holds a cookie (a script, a stale tab) cannot remove
|
|
118
|
-
// the login wall by itself. The code is minted here — unlike setup codes
|
|
119
|
-
// it is never printed to stdout or served to an unauthenticated caller.
|
|
120
|
-
if (urlPath === '/api/access/disable/code' && method === 'POST') {
|
|
121
|
-
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
122
|
-
const issued = accessAuth.createDisableCode();
|
|
123
|
-
return sendJSON(res, 200, { code: issued.code, expiresAt: issued.expiresAt, ttlMs: accessAuth.DISABLE_TTL_MS });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// The QR is an image so a phone can open it with the system camera. It
|
|
127
|
-
// encodes the confirmation page, which is what makes the code itself
|
|
128
|
-
// unnecessary to type on the scanning device.
|
|
129
|
-
if (urlPath === '/api/access/disable/qr' && method === 'GET') {
|
|
130
|
-
if (!activeSession) return sendJSON(res, 401, { error: 'Sign in is required', code: 'EAUTH_REQUIRED' });
|
|
131
|
-
const code = typeof parsed.query.code === 'string' ? parsed.query.code : '';
|
|
132
|
-
if (!accessAuth.disableCodeValid(code)) return sendJSON(res, 404, { error: 'Disable code is missing or expired', code: 'EDISABLE_CODE' });
|
|
133
|
-
const confirmUrl = servedOrigin + '/#/disable-access?code=' + encodeURIComponent(accessAuth.normalizeCode(code));
|
|
134
|
-
const svg = qr.svg(confirmUrl);
|
|
135
|
-
res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
136
|
-
res.end(svg);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (urlPath === '/api/access/disable' && method === 'POST') {
|
|
141
|
-
const body = await readJsonOr400(req, res);
|
|
142
|
-
if (!body) return;
|
|
143
|
-
// An invalid code is a failed guess, so it counts against the same
|
|
144
|
-
// per-address limit as a wrong password. The confirmation page is
|
|
145
|
-
// deliberately reachable without a session, so nothing else slows down
|
|
146
|
-
// a caller trying codes in a loop.
|
|
147
|
-
if (!checkAccessAttempts(req)) return sendJSON(res, 429, { error: 'Too many attempts; wait a minute', code: 'ERATE_LIMIT' });
|
|
148
|
-
if (!accessAuth.disableCodeValid(body.code)) {
|
|
149
|
-
recordAccessFailure(req);
|
|
150
|
-
return sendJSON(res, 401, { error: 'That code is invalid or expired', code: 'EDISABLE_CODE' });
|
|
151
|
-
}
|
|
152
|
-
if (!accessAuth.consumeDisableCode(body.code)) {
|
|
153
|
-
recordAccessFailure(req);
|
|
154
|
-
return sendJSON(res, 409, { error: 'That code was already used or expired', code: 'EDISABLE_CODE' });
|
|
155
|
-
}
|
|
156
|
-
clearAccessFailures(req);
|
|
157
|
-
// Consume every other outstanding invitation and turn the gate off.
|
|
158
|
-
// Sessions are intentionally kept: with the gate off they authorize
|
|
159
|
-
// nothing, and holding on to them lets the device that disabled access
|
|
160
|
-
// re-enable it without having to prove the password again.
|
|
161
|
-
settings.getDb().prepare('DELETE FROM access_disable_codes').run();
|
|
162
|
-
settings.getDb().prepare('DELETE FROM access_setup_codes').run();
|
|
163
|
-
accessAuth.setEnabled(false);
|
|
164
|
-
notifyAccessChange(false);
|
|
165
|
-
return sendJSON(res, 200, { ok: true, enabled: false, configured: accessAuth.configured() });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Turning protection back on is safe to do from an unauthenticated screen,
|
|
169
|
-
// so it accepts any of the store's proofs: a live session, the account
|
|
170
|
-
// password, or a CLI setup code. That keeps recovery possible even after
|
|
171
|
-
// session cookies have expired while access was off.
|
|
172
|
-
if (urlPath === '/api/access/enable' && method === 'POST') {
|
|
173
|
-
const body = await readJsonOr400(req, res);
|
|
174
|
-
if (!body) return;
|
|
175
|
-
const account = accessAuth.user();
|
|
176
|
-
if (!account) return sendJSON(res, 409, { error: 'Create the access user before enabling protection', code: 'ENOTCONFIGURED' });
|
|
177
|
-
const bySession = !!activeSession;
|
|
178
|
-
const byPassword = !bySession && account
|
|
179
|
-
&& accessAuth.verifyPassword(body.username || account.username, body.password);
|
|
180
|
-
const bySetupCode = !bySession && !byPassword && accessAuth.setupCodeValid(body.code);
|
|
181
|
-
if (!bySession && !byPassword && !bySetupCode) {
|
|
182
|
-
return sendJSON(res, 401, { error: 'Sign in, or enter the account password, to enable access', code: 'EAUTH_REQUIRED' });
|
|
183
|
-
}
|
|
184
|
-
if (bySetupCode && !accessAuth.consumeSetupCode(body.code)) {
|
|
185
|
-
return sendJSON(res, 409, { error: 'Setup code was already used or expired', code: 'ESETUP_CODE' });
|
|
186
|
-
}
|
|
187
|
-
accessAuth.setEnabled(true);
|
|
188
|
-
notifyAccessChange(true);
|
|
189
|
-
return sendJSON(res, 200, { ok: true, enabled: true, configured: true });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
92
|
// Password change from an authenticated browser session. Requires a real
|
|
193
93
|
// cookie session (a Basic-auth API client cannot ask to rotate the
|
|
194
94
|
// password through this endpoint). The current session survives the
|
|
@@ -23,18 +23,21 @@ const {
|
|
|
23
23
|
//
|
|
24
24
|
// A single persistent command-line child per project (the platform shell:
|
|
25
25
|
// cmd.exe on Windows, the user's $SHELL or /bin/sh on POSIX) with
|
|
26
|
-
// stdin/stdout/stderr
|
|
26
|
+
// stdin/stdout/stderr carried over HTTP. All commands run in the project
|
|
27
27
|
// directory ("default path to the project"). The session is created with
|
|
28
28
|
// `windowsHide` and no window is ever shown; output streaming rides the
|
|
29
29
|
// SSE endpoint (see startCliSession below).
|
|
30
30
|
//
|
|
31
|
-
// The child
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
31
|
+
// The child runs on a pseudo-terminal when the host can allocate one (see
|
|
32
|
+
// src/pty.js), which is what lets prompting programs ask a question. Where
|
|
33
|
+
// no PTY is available the session falls back to piped stdio, and commands
|
|
34
|
+
// that require a TTY (REPLs, `cmd.exe` interactive prompts like `del`
|
|
35
|
+
// confirmation) fail or exit immediately — the documented limitation, same
|
|
36
|
+
// as the native `shell` tool. Everything non-interactive works exactly like
|
|
37
|
+
// a real Command Prompt in both modes.
|
|
36
38
|
|
|
37
39
|
const { spawn } = require('node:child_process');
|
|
40
|
+
const pty = require('./pty.js');
|
|
38
41
|
|
|
39
42
|
const cliSessions = new Map(); // projectDir -> { child, projectDir, id, startedAt }
|
|
40
43
|
|
|
@@ -85,26 +88,9 @@ function cliShellMeta() {
|
|
|
85
88
|
};
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
// Load node-pty once. It is an optional native dependency: a platform with
|
|
89
|
-
// no prebuilt binary (and no C++ toolchain) still installs mouaif, and the
|
|
90
|
-
// session silently falls back to the piped spawn below. `require` is
|
|
91
|
-
// wrapped so a missing/broken addon is a degraded mode, not a crash.
|
|
92
|
-
let ptyModule = null;
|
|
93
|
-
let ptyLoadAttempted = false;
|
|
94
|
-
function loadPty() {
|
|
95
|
-
if (ptyLoadAttempted) return ptyModule;
|
|
96
|
-
ptyLoadAttempted = true;
|
|
97
|
-
try {
|
|
98
|
-
ptyModule = require('node-pty');
|
|
99
|
-
} catch {
|
|
100
|
-
ptyModule = null;
|
|
101
|
-
}
|
|
102
|
-
return ptyModule;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
91
|
// Start the persistent session (idempotent) and return the session handle.
|
|
106
92
|
//
|
|
107
|
-
// The session runs on a pseudo-terminal when
|
|
93
|
+
// The session runs on a pseudo-terminal when one can be allocated. That
|
|
108
94
|
// matters for prompting programs: over pipes (`stdio: ['pipe', ...]`) the
|
|
109
95
|
// child's stdin is not a TTY, so a program that asks a question either gets
|
|
110
96
|
// an immediate EOF or refuses to prompt at all — `npm publish` under 2FA
|
|
@@ -113,19 +99,24 @@ function loadPty() {
|
|
|
113
99
|
// screen, the user types the answer into the modal's prompt line, and it is
|
|
114
100
|
// delivered to the still-running child.
|
|
115
101
|
//
|
|
102
|
+
// The TTY comes from src/pty.js, which allocates it with util-linux
|
|
103
|
+
// `script(1)` — no native addon, so `npm install` needs no C++ toolchain.
|
|
104
|
+
// Where no PTY can be allocated (Windows, BSD/macOS `script`, a container
|
|
105
|
+
// without util-linux) the piped spawn below takes over, and the modal shows a
|
|
106
|
+
// non-interactive session.
|
|
107
|
+
//
|
|
116
108
|
// A PTY merges stdout and stderr into one stream, so `attachCliStream`
|
|
117
109
|
// labels every chunk `stdout`; there is no separate stderr channel to
|
|
118
|
-
// preserve.
|
|
119
|
-
//
|
|
110
|
+
// preserve. The piped fallback keeps the old triple and stderr keeps its own
|
|
111
|
+
// channel.
|
|
120
112
|
function ensureCliSession(projectDir) {
|
|
121
113
|
const key = String(projectDir || '');
|
|
122
114
|
const existing = cliSessions.get(key);
|
|
123
115
|
if (existing && existing.child && !existing.child.killed) return existing;
|
|
124
116
|
const meta = cliShellMeta();
|
|
125
|
-
|
|
126
|
-
let child;
|
|
117
|
+
let child = null;
|
|
127
118
|
let isPty = false;
|
|
128
|
-
if (pty
|
|
119
|
+
if (pty.isAvailable()) {
|
|
129
120
|
try {
|
|
130
121
|
// The shell is only interactive once it has a TTY, so ask for `-i`
|
|
131
122
|
// here rather than in cliShellMeta (the piped fallback must not: bash
|
|
@@ -133,18 +124,16 @@ function ensureCliSession(projectDir) {
|
|
|
133
124
|
const ptyArgs = (!meta.windows && meta.args.indexOf('-i') === -1)
|
|
134
125
|
? meta.args.concat('-i')
|
|
135
126
|
: meta.args;
|
|
136
|
-
child = pty.
|
|
137
|
-
name: 'xterm-256color',
|
|
138
|
-
cols: meta.cols,
|
|
139
|
-
rows: meta.rows,
|
|
127
|
+
child = pty.spawnPty(meta.exe, ptyArgs, {
|
|
140
128
|
cwd: projectDir,
|
|
141
129
|
// The child inherits the server's env; force a colour-capable TERM
|
|
142
130
|
// so utilities that gate formatting on terminfo behave.
|
|
143
131
|
env: Object.assign({}, process.env, { TERM: process.env.TERM || 'xterm-256color' })
|
|
144
132
|
});
|
|
145
|
-
isPty =
|
|
133
|
+
isPty = !!child;
|
|
146
134
|
} catch {
|
|
147
135
|
child = null;
|
|
136
|
+
isPty = false;
|
|
148
137
|
}
|
|
149
138
|
}
|
|
150
139
|
if (!child) {
|
|
@@ -159,7 +148,7 @@ function ensureCliSession(projectDir) {
|
|
|
159
148
|
projectDir,
|
|
160
149
|
child,
|
|
161
150
|
startedAt: Date.now(),
|
|
162
|
-
// True when the child runs on a pseudo-terminal (see
|
|
151
|
+
// True when the child runs on a pseudo-terminal (see src/pty.js).
|
|
163
152
|
pty: isPty,
|
|
164
153
|
// Record the platform so command writes use the correct line
|
|
165
154
|
// terminator: CRLF for cmd.exe on Windows, LF for sh/bash on POSIX.
|
|
@@ -167,7 +156,7 @@ function ensureCliSession(projectDir) {
|
|
|
167
156
|
};
|
|
168
157
|
hookCliExit();
|
|
169
158
|
cliSessions.set(key, session);
|
|
170
|
-
// Reap on exit so a closed session doesn't leak.
|
|
159
|
+
// Reap on exit so a closed session doesn't leak. A PTY reports exit
|
|
171
160
|
// through `onExit`; a piped child uses the standard `exit` event.
|
|
172
161
|
if (isPty) child.onExit(() => { if (cliSessions.get(key) === session) cliSessions.delete(key); });
|
|
173
162
|
else child.on('exit', () => { if (cliSessions.get(key) === session) cliSessions.delete(key); });
|
package/src/server-shared.js
CHANGED
|
@@ -344,11 +344,6 @@ function authorizeBrowserRequest(req, res, sessionToken, publicOrigin) {
|
|
|
344
344
|
|
|
345
345
|
function authorizeAccessRequest(req, res, authEnabled) {
|
|
346
346
|
if (!authEnabled) return true;
|
|
347
|
-
// Access can be turned off from inside the app (one-time disable code).
|
|
348
|
-
// serverConfig.authEnabled was resolved at worker start; re-checking the
|
|
349
|
-
// store here makes the change effective immediately, without waiting for a
|
|
350
|
-
// restart — the /#/disable-access page is the point of no return.
|
|
351
|
-
if (accessAuth.disabled()) return true;
|
|
352
347
|
if (!accessAuth.configured()) {
|
|
353
348
|
// Preserve the existing loopback CLI/API workflow until the user opts in;
|
|
354
349
|
// browser traffic is held at setup so the web UI cannot expose app data.
|
|
@@ -564,16 +559,8 @@ function clearAccessFailures(req) {
|
|
|
564
559
|
|
|
565
560
|
function publicAccessStatus(authEnabled = true) {
|
|
566
561
|
const account = accessAuth.user();
|
|
567
|
-
const disabled = accessAuth.disabled();
|
|
568
|
-
// `armed` is whether the process was started with an auth flag at all,
|
|
569
|
-
// independent of the in-app disable switch. The UI uses it to decide
|
|
570
|
-
// between "protection is off because you turned it off" and "this server
|
|
571
|
-
// was never asked to protect anything".
|
|
572
|
-
const armed = !!authEnabled || disabled;
|
|
573
562
|
return {
|
|
574
|
-
|
|
575
|
-
enabled: !!authEnabled && !disabled,
|
|
576
|
-
disabled,
|
|
563
|
+
enabled: !!authEnabled,
|
|
577
564
|
configured: !!account,
|
|
578
565
|
user: account ? account.username : null,
|
|
579
566
|
passkeyCount: account ? accessAuth.passkeys().length : 0
|