@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.
- package/dist/commands/config.js +9 -0
- package/dist/commands/ui.js +37 -5
- package/dist/service/serve.d.ts +7 -0
- package/dist/service/serve.js +20 -10
- package/dist/tui/app.js +2 -1
- package/package.json +1 -1
package/dist/commands/config.js
CHANGED
|
@@ -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":
|
package/dist/commands/ui.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
12
|
-
details:
|
|
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) {
|
package/dist/service/serve.d.ts
CHANGED
|
@@ -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;
|
package/dist/service/serve.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
}),
|
|
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