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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/bin/mouaif.js +1 -5
  3. package/frontend/dist/assets/{AgentFilePicker-CcKLJorU.js → AgentFilePicker-ZW6vm5Hy.js} +1 -1
  4. package/frontend/dist/assets/{CliModal-Hs5phmNZ.js → CliModal-D2cX4W0a.js} +1 -1
  5. package/frontend/dist/assets/{DictationPage-BI23lp42.js → DictationPage-Dwb6gGNi.js} +1 -1
  6. package/frontend/dist/assets/{FileEditor-DDl31c6d.js → FileEditor-BqgbeJHy.js} +1 -1
  7. package/frontend/dist/assets/{GitModal-3EC_gpJ5.js → GitModal-BPaOnX1a.js} +1 -1
  8. package/frontend/dist/assets/{Inspector-Ba3R1w04.js → Inspector-C88hoEcC.js} +1 -1
  9. package/frontend/dist/assets/{SettingsAbout-bvZGDEDw.js → SettingsAbout-D5gHujOy.js} +1 -1
  10. package/frontend/dist/assets/{SettingsActions-Dk6WX9jv.js → SettingsActions-YrcVF8D3.js} +1 -1
  11. package/frontend/dist/assets/{SettingsAgents-BNV0MgDB.js → SettingsAgents-BVwBxPbO.js} +1 -1
  12. package/frontend/dist/assets/{SettingsDefaults-DbMmQbzc.js → SettingsDefaults-CMSIeSt8.js} +1 -1
  13. package/frontend/dist/assets/{SettingsHiddenContent-BZ2sloH1.js → SettingsHiddenContent-XE1HiHzN.js} +1 -1
  14. package/frontend/dist/assets/{SettingsMcp-DOrfbQd1.js → SettingsMcp-BAK8U0e4.js} +1 -1
  15. package/frontend/dist/assets/{SettingsMcpEdit-BGMQ2CWC.js → SettingsMcpEdit-j21BWCAH.js} +1 -1
  16. package/frontend/dist/assets/{SettingsMcpRegistry-BywXee_A.js → SettingsMcpRegistry-DH5YAigF.js} +1 -1
  17. package/frontend/dist/assets/{SettingsNotifications-B0LEs11a.js → SettingsNotifications-DJt0f8Jn.js} +1 -1
  18. package/frontend/dist/assets/{SettingsPricing-BAg33iVF.js → SettingsPricing-Csy5MjG-.js} +1 -1
  19. package/frontend/dist/assets/{SettingsProject-DNrKhCcZ.js → SettingsProject-Cry5UltF.js} +1 -1
  20. package/frontend/dist/assets/{SettingsProjects-IqkBfDcm.js → SettingsProjects-d3wgiADv.js} +1 -1
  21. package/frontend/dist/assets/{SettingsPrompts-BgeiASuk.js → SettingsPrompts-B9ECDKEa.js} +1 -1
  22. package/frontend/dist/assets/{SettingsProviders-k0xJN0IK.js → SettingsProviders-BalA35c9.js} +1 -1
  23. package/frontend/dist/assets/{SettingsTags-B5kjFdQi.js → SettingsTags-D_1AgCwY.js} +1 -1
  24. package/frontend/dist/assets/index-BDkCsON6.js +61 -0
  25. package/frontend/dist/assets/index-FzhcinOk.css +1 -0
  26. package/frontend/dist/index.html +2 -2
  27. package/frontend/dist/sw.js +1 -1
  28. package/package.json +6 -7
  29. package/src/access-auth.js +1 -86
  30. package/src/http-server.js +2 -9
  31. package/src/pty.js +189 -0
  32. package/src/server-handlers-access.js +1 -101
  33. package/src/server-handlers-tools.js +25 -36
  34. package/src/server-shared.js +1 -14
  35. package/src/statusBar.js +19 -8
  36. package/frontend/dist/assets/index-BGvI4n0T.js +0 -61
  37. package/frontend/dist/assets/index-CANPYzQg.css +0 -1
@@ -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, one-time disable codes, and WebAuthn credentials. Provider
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,
@@ -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 || accessAuth.disabled() || !!accessAuth.session(accessToken);
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
- const status = publicAccessStatus(serverConfig.authEnabled);
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 piped over HTTP. All commands run in the project
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 is intentionally spawned WITHOUT a TTY (pipe stdio). Commands
32
- // that require an interactive TTY (REPLs, `cmd.exe` interactive prompts
33
- // like `del` confirmation) will fail or exit immediately a documented
34
- // limitation, same as the native `shell` tool. Everything non-interactive
35
- // works exactly like a real Command Prompt.
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 node-pty is available. That
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. When the PTY is unavailable the old piped triple is used and
119
- // stderr keeps its own channel.
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
- const pty = loadPty();
126
- let child;
117
+ let child = null;
127
118
  let isPty = false;
128
- if (pty && typeof pty.spawn === 'function') {
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.spawn(meta.exe, ptyArgs, {
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 = true;
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 loadPty above).
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. node-pty reports exit
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); });
@@ -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
- armed,
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