@otto-code/brain 0.7.5 → 0.7.6

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.
@@ -45,6 +45,15 @@ function applyKey(config, key, value) {
45
45
  config.runtime.source = value;
46
46
  return;
47
47
  case "auth.mode":
48
+ // mode=token without a stored token would serve ungated (withAuth has no
49
+ // key to check), so refuse to persist that state. Set auth.token first.
50
+ if (value === "token" && !config.auth.token) {
51
+ throw new CommandError({
52
+ code: "TOKEN_REQUIRED",
53
+ message: "auth.mode=token requires auth.token to be set first",
54
+ details: "run `otto brain config set auth.token <token>` before enabling token auth",
55
+ });
56
+ }
48
57
  config.auth.mode = value;
49
58
  return;
50
59
  case "auth.token":
@@ -4,14 +4,46 @@ import { resolveRuntime } from "../runtime/index.js";
4
4
  export function addUiOptions(cmd) {
5
5
  return cmd.description("Launch the interactive full-screen UI");
6
6
  }
7
- export async function runUiCommand(_options, _command) {
8
- if (!process.stdout.isTTY) {
9
- throw new CommandError({
7
+ const NON_INTERACTIVE_HINT = "use `scan`, `serve`, `calibrate`, or `sweep` instead";
8
+ /**
9
+ * The bundled Windows `otto` runs the CLI inside Otto.exe with
10
+ * ELECTRON_RUN_AS_NODE=1. Otto.exe is an IMAGE_SUBSYSTEM_WINDOWS_GUI binary, so
11
+ * Node classifies its inherited stdio as non-TTY even from a real console:
12
+ * measured in a freshly allocated console, Otto.exe reports
13
+ * stdout/stdin.isTTY=false where node.exe in the same console reports true.
14
+ * stdin is the fatal half: the TUI needs setRawMode and there is no console
15
+ * input to switch. No launcher change fixes this while Otto.exe is the only
16
+ * Node host shipped in the installer, so point the user at the npm CLI, which
17
+ * runs on console-subsystem node.exe.
18
+ */
19
+ function isBundledWindowsCli() {
20
+ return process.platform === "win32" && Boolean(process.versions.electron);
21
+ }
22
+ function noTtyError() {
23
+ if (isBundledWindowsCli()) {
24
+ return new CommandError({
10
25
  code: "NO_TTY",
11
- message: "the interactive UI needs a TTY",
12
- details: "use `scan`, `serve`, `calibrate`, or `sweep` instead",
26
+ message: "the bundled Windows CLI cannot run the interactive UI",
27
+ details: [
28
+ "`otto` from the desktop app runs inside Otto.exe, a GUI-subsystem binary",
29
+ "with no console input, so the full-screen UI cannot start.",
30
+ "For interactive commands install the standalone CLI: npm i -g @otto-code/cli",
31
+ "Every non-interactive command still works here: `scan`, `serve`, `calibrate`, `sweep`.",
32
+ ].join("\n "),
13
33
  });
14
34
  }
35
+ return new CommandError({
36
+ code: "NO_TTY",
37
+ message: "the interactive UI needs a TTY",
38
+ details: NON_INTERACTIVE_HINT,
39
+ });
40
+ }
41
+ export async function runUiCommand(_options, _command) {
42
+ // Both streams matter: the screen needs stdout, and onKeys() needs stdin raw
43
+ // mode. Gating on stdout alone let a stdin-less run start and then hang.
44
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
45
+ throw noTtyError();
46
+ }
15
47
  const config = loadBrainConfig();
16
48
  const runtime = resolveRuntime(config);
17
49
  if (!runtime) {
@@ -19,6 +19,13 @@ import { Supervisor } from "./supervisor.js";
19
19
  * what the relay accepted), or the brain's own `x-otto-brain-token`.
20
20
  */
21
21
  export declare function extractToken(req: http.IncomingMessage): string | null;
22
+ /**
23
+ * The one derivation of the effective auth token. `mode: "token"` with a null or
24
+ * empty token is NO auth — the bind guard and withAuth both read this, so they
25
+ * cannot disagree (a mode-only guard once let mode=token + token=null bind
26
+ * non-loopback and then serve every route ungated).
27
+ */
28
+ export declare function effectiveAuthToken(config: BrainConfig): string | null;
22
29
  export interface StartServiceOptions {
23
30
  config: BrainConfig;
24
31
  modelNeedle?: string;
@@ -68,9 +68,17 @@ export function extractToken(req) {
68
68
  const header = req.headers["x-otto-brain-token"];
69
69
  return typeof header === "string" ? header : null;
70
70
  }
71
+ /**
72
+ * The one derivation of the effective auth token. `mode: "token"` with a null or
73
+ * empty token is NO auth — the bind guard and withAuth both read this, so they
74
+ * cannot disagree (a mode-only guard once let mode=token + token=null bind
75
+ * non-loopback and then serve every route ungated).
76
+ */
77
+ export function effectiveAuthToken(config) {
78
+ return config.auth.mode === "token" && config.auth.token ? config.auth.token : null;
79
+ }
71
80
  /** Gate the router with a bearer token when configured; /health stays open. */
72
- function withAuth(inner, config) {
73
- const token = config.auth.mode === "token" ? config.auth.token : null;
81
+ function withAuth(inner, token) {
74
82
  if (!token)
75
83
  return inner;
76
84
  return (req, res) => {
@@ -102,19 +110,21 @@ export async function startService({ config, modelNeedle, env = process.env, onL
102
110
  : config.listen.host;
103
111
  const displayHost = tlsOptions?.hostname ?? bindHost;
104
112
  // Auth is orthogonal to transport: TLS encrypts the pipe, a token authorizes the
105
- // caller. A non-loopback bind still needs a token even over HTTPS.
113
+ // caller. A non-loopback bind still needs an actual token even over HTTPS
114
+ // gate on the token itself, not auth.mode, or mode=token with no token binds open.
115
+ const authToken = effectiveAuthToken(config);
106
116
  if (!isLoopback(bindHost) &&
107
- config.auth.mode !== "token" &&
117
+ !authToken &&
108
118
  !config.allowInsecureBind &&
109
119
  env.OTTO_BRAIN_ALLOW_INSECURE !== "1") {
110
120
  throw new CommandError({
111
121
  code: "INSECURE_BIND",
112
122
  message: `refusing to bind ${bindHost} without auth`,
113
- details: "set auth.mode=token, or allowInsecureBind=true for an open trusted-network share " +
114
- "(or OTTO_BRAIN_ALLOW_INSECURE=1 to override)",
123
+ details: "set auth.mode=token with a non-empty auth.token, or allowInsecureBind=true for an " +
124
+ "open trusted-network share (or OTTO_BRAIN_ALLOW_INSECURE=1 to override)",
115
125
  });
116
126
  }
117
- const store = loadProfilesStore();
127
+ const store = loadProfilesStore(paths);
118
128
  const catalog = scanModels(config, env);
119
129
  const needle = modelNeedle ?? config.defaultModel ?? store.lastModelId ?? undefined;
120
130
  const model = pickModel(catalog, needle);
@@ -165,7 +175,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
165
175
  }
166
176
  await supervisor.start(target, fitProfile);
167
177
  store.lastModelId = target.id;
168
- saveProfilesStore(store);
178
+ saveProfilesStore(store, paths);
169
179
  };
170
180
  const loadModel = (target) => {
171
181
  const run = modelSwitchChain.then(() => loadModelUnsafe(target));
@@ -221,7 +231,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
221
231
  getDefaultModel: () => config.defaultModel,
222
232
  applyConfigPatch,
223
233
  getAllowConfigWrite: () => config.allowRemoteConfig,
224
- }), config);
234
+ }), authToken);
225
235
  // TLS terminates in-process when configured; otherwise plain HTTP. The cert
226
236
  // manager issues/generates the first keypair before we listen, and hot-swaps
227
237
  // the secure context on renewal without dropping connections.
@@ -249,7 +259,7 @@ export async function startService({ config, modelNeedle, env = process.env, onL
249
259
  certManager?.start();
250
260
  await supervisor.start(model, profile);
251
261
  store.lastModelId = model.id;
252
- saveProfilesStore(store);
262
+ saveProfilesStore(store, paths);
253
263
  writePidFile({
254
264
  pid: process.pid,
255
265
  host: bindHost,
package/dist/tui/app.js CHANGED
@@ -11,6 +11,7 @@ import * as archive from "../ops/archive.js";
11
11
  import { Supervisor } from "../service/supervisor.js";
12
12
  import { createRouter, Telemetry } from "../service/router.js";
13
13
  import * as sysmon from "../sysmon.js";
14
+ import { resolveVersion } from "../version.js";
14
15
  import http from "node:http";
15
16
  // The config panel holds short fields, so keep it compact and give the rest of
16
17
  // the width to the model list (long model names need the room).
@@ -1394,7 +1395,7 @@ export class App {
1394
1395
  this.renderFitted(lines);
1395
1396
  }
1396
1397
  header(cols) {
1397
- const title = `${style.bold}${style.brightCyan}Otto Brain${style.reset}`;
1398
+ const title = `${style.bold}${style.brightCyan}Otto Brain${style.reset}${style.grey} v${resolveVersion()}${style.reset}`;
1398
1399
  const rt = `${style.grey}llama.cpp ${this.runtime.label} v${this.runtime.version}${style.reset}`;
1399
1400
  const g = this.gpuInfo
1400
1401
  ? `${style.grey}${this.gpuInfo.name} · ${vram.formatGiB(this.gpuInfo.usedBytes)}/${vram.formatGiB(this.gpuInfo.totalBytes)}${style.reset}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otto-code/brain",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Otto Brain - self-contained host for local GGUF models, with measured VRAM budgeting and reasoning-budget control",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "bin": {