@wrongstack/webui-server 0.299.0 → 0.300.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.
@@ -77,6 +77,10 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
77
77
  // Display-only toggles (purely visual, persisted in localStorage via Zustand).
78
78
  "groupToolCalls",
79
79
  "showThinkingLogs",
80
+ // v15: auto-collapse of the chat input under the history (opt-in display
81
+ // toggle, default off). Whitelisted so the key survives `prefs.update`
82
+ // round-trips without tripping the "unknown preference key" rejection.
83
+ "autoCollapseInput",
80
84
  // v11 Display parity: inverse fsAccess flag.
81
85
  "allowOutsideProjectRoot",
82
86
  // v13 Display parity (TUI SettingsPicker fields 42 & 43): the read tool
@@ -401,6 +405,51 @@ function validateModelSwitchPayload(payload) {
401
405
  }
402
406
  };
403
407
  }
408
+ function validateModelFallbackChoicePayload(payload) {
409
+ if (!isRecord2(payload)) {
410
+ return {
411
+ ok: false,
412
+ message: "model.fallback_choice payload must be an object"
413
+ };
414
+ }
415
+ const requestId = payload["requestId"];
416
+ if (typeof requestId !== "string" || requestId.trim().length === 0) {
417
+ return {
418
+ ok: false,
419
+ message: "model.fallback_choice payload.requestId must be a non-empty string"
420
+ };
421
+ }
422
+ const providerId = payload["providerId"];
423
+ const model = payload["model"];
424
+ const autoSwitch = payload["autoSwitch"];
425
+ if (providerId !== void 0 && typeof providerId !== "string") {
426
+ return {
427
+ ok: false,
428
+ message: "model.fallback_choice payload.providerId must be a string when provided"
429
+ };
430
+ }
431
+ if (model !== void 0 && typeof model !== "string") {
432
+ return {
433
+ ok: false,
434
+ message: "model.fallback_choice payload.model must be a string when provided"
435
+ };
436
+ }
437
+ if (autoSwitch !== void 0 && typeof autoSwitch !== "boolean") {
438
+ return {
439
+ ok: false,
440
+ message: "model.fallback_choice payload.autoSwitch must be a boolean when provided"
441
+ };
442
+ }
443
+ return {
444
+ ok: true,
445
+ value: {
446
+ requestId: requestId.trim(),
447
+ ...typeof providerId === "string" ? { providerId } : {},
448
+ ...typeof model === "string" ? { model } : {},
449
+ ...typeof autoSwitch === "boolean" ? { autoSwitch } : {}
450
+ }
451
+ };
452
+ }
404
453
  var AUTONOMY_VALUES2 = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
405
454
  function validateMailboxMessagesPayload(payload) {
406
455
  if (payload === void 0) return { ok: true, value: void 0 };
@@ -4331,13 +4380,10 @@ function createConversationOperations(ctx) {
4331
4380
 
4332
4381
  // src/server/context-editor.ts
4333
4382
  import { createHash } from "node:crypto";
4334
- import net from "node:net";
4335
4383
  import {
4336
4384
  ALLOWED_IMAGE_MEDIA_TYPES,
4337
4385
  base64DecodedBytes,
4338
4386
  isAllowedImageMediaType,
4339
- isPrivateIPv4,
4340
- isPrivateIPv6,
4341
4387
  isValidImageBase64,
4342
4388
  MAX_INCOMING_IMAGE_BYTES,
4343
4389
  repairToolUseAdjacency
@@ -4405,40 +4451,7 @@ var REVISION_PREFIX = "wrongstack-context-editor-v1\0";
4405
4451
  var MAX_MESSAGE_COUNT_GROWTH = 10;
4406
4452
  var MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
4407
4453
  var MAX_STRING_LENGTH = 8 * 1024 * 1024;
4408
- var MAX_IMAGE_URL_LENGTH = 2048;
4409
- function imageUrlRejectionReason(url) {
4410
- if (url.length > MAX_IMAGE_URL_LENGTH) {
4411
- return `image.source.url exceeds ${MAX_IMAGE_URL_LENGTH} characters.`;
4412
- }
4413
- let parsed;
4414
- try {
4415
- parsed = new URL(url);
4416
- } catch {
4417
- return "image.source.url must be an absolute URL.";
4418
- }
4419
- if (parsed.protocol !== "https:") {
4420
- return `image.source.url must use https (got "${parsed.protocol}").`;
4421
- }
4422
- if (parsed.username !== "" || parsed.password !== "") {
4423
- return "image.source.url must not embed credentials.";
4424
- }
4425
- const host = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") ? parsed.hostname.slice(1, -1) : parsed.hostname;
4426
- const bareHost = host.endsWith(".") ? host.slice(0, -1) : host;
4427
- if (bareHost === "") {
4428
- return "image.source.url must include a hostname.";
4429
- }
4430
- if (bareHost === "localhost" || bareHost.endsWith(".localhost")) {
4431
- return "image.source.url must not target localhost.";
4432
- }
4433
- const family = net.isIP(bareHost);
4434
- if (family === 4 && isPrivateIPv4(bareHost)) {
4435
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4436
- }
4437
- if (family === 6 && isPrivateIPv6(bareHost)) {
4438
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4439
- }
4440
- return void 0;
4441
- }
4454
+ var MAX_REMOVAL_COUNT = 4096;
4442
4455
  function isRecord3(value) {
4443
4456
  return value !== null && typeof value === "object" && !Array.isArray(value);
4444
4457
  }
@@ -4447,7 +4460,7 @@ function canonicalize(value) {
4447
4460
  if (isRecord3(value)) {
4448
4461
  const sorted = {};
4449
4462
  for (const key of Object.keys(value).sort()) {
4450
- if (key === "_estTokens") continue;
4463
+ if (key === "_estTokens" || key === "_toolErrorInfo") continue;
4451
4464
  const item = value[key];
4452
4465
  if (item === void 0) continue;
4453
4466
  sorted[key] = canonicalize(item);
@@ -4478,6 +4491,12 @@ function isMessageRole(value) {
4478
4491
  function isPlainJsonObject(value) {
4479
4492
  return isRecord3(value);
4480
4493
  }
4494
+ function splitsSurrogatePair(text, offset) {
4495
+ if (offset <= 0 || offset >= text.length) return false;
4496
+ const previous = text.charCodeAt(offset - 1);
4497
+ const next = text.charCodeAt(offset);
4498
+ return previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343;
4499
+ }
4481
4500
  function validateCacheControl(value, path30, errors) {
4482
4501
  if (value === void 0) return void 0;
4483
4502
  if (!isRecord3(value) || value["type"] !== "ephemeral") {
@@ -4688,19 +4707,13 @@ function validateBlock(value, path30, errors) {
4688
4707
  );
4689
4708
  return void 0;
4690
4709
  }
4691
- const urlError = imageUrlRejectionReason(url);
4692
- if (urlError !== void 0) {
4693
- error(errors, `${path30}/source/url`, "UNSAFE_IMAGE_URL", urlError);
4694
- return void 0;
4695
- }
4696
- return {
4697
- type: "image",
4698
- source: {
4699
- type: "url",
4700
- ...typeof mediaType === "string" ? { media_type: mediaType } : {},
4701
- url
4702
- }
4703
- };
4710
+ error(
4711
+ errors,
4712
+ `${path30}/source/url`,
4713
+ "UNSAFE_IMAGE_URL",
4714
+ "URL image sources are not allowed in context editor proposals; use an ingested base64 image."
4715
+ );
4716
+ return void 0;
4704
4717
  }
4705
4718
  case "thinking": {
4706
4719
  const thinking = value["thinking"];
@@ -4857,6 +4870,14 @@ function warningsForMessage(message, index) {
4857
4870
  message: "This block contains provider replay metadata and should only be removed with the whole turn if no longer needed."
4858
4871
  });
4859
4872
  }
4873
+ if (block.type === "image" && block.source.type === "url") {
4874
+ warnings.push({
4875
+ path: `/messages/${index}/content/${blockIndex}/source/url`,
4876
+ code: "UNSAFE_IMAGE_URL",
4877
+ severity: "danger",
4878
+ message: "URL image sources cannot be retained in context editor proposals; remove the whole message before applying other edits."
4879
+ });
4880
+ }
4860
4881
  if (block.type === "tool_result" && block.content.length > 2e4) {
4861
4882
  warnings.push({
4862
4883
  path: `/messages/${index}/content/${blockIndex}`,
@@ -4884,6 +4905,21 @@ function metricFor(ctx, messages, tools) {
4884
4905
  fullRequestTokens: breakdown.total
4885
4906
  };
4886
4907
  }
4908
+ function isToolResultMessage(message) {
4909
+ return Boolean(
4910
+ message?.role === "user" && Array.isArray(message.content) && message.content.length > 0 && message.content.every((block) => block.type === "tool_result")
4911
+ );
4912
+ }
4913
+ function pairedAssistantIndices(messages, userIndex) {
4914
+ if (messages[userIndex]?.role !== "user" || isToolResultMessage(messages[userIndex])) return [];
4915
+ const paired = [];
4916
+ for (let index = userIndex + 1; index < messages.length; index += 1) {
4917
+ const message = messages[index];
4918
+ if (message?.role === "user" && !isToolResultMessage(message)) break;
4919
+ if (message?.role === "assistant") paired.push(index);
4920
+ }
4921
+ return paired;
4922
+ }
4887
4923
  function buildContextEditorSnapshot(ctx, tools) {
4888
4924
  const messages = ctx.messages.map(
4889
4925
  (message) => ({
@@ -4913,11 +4949,165 @@ function buildContextEditorSnapshot(ctx, tools) {
4913
4949
  tokens: messageTokens(message.content),
4914
4950
  preview: breakdown.messages.breakdown[index]?.preview ?? "",
4915
4951
  blockCount: Array.isArray(message.content) ? message.content.length : null,
4916
- warnings: warningsForMessage(message, index)
4952
+ warnings: warningsForMessage(message, index),
4953
+ pairedAssistantIndices: pairedAssistantIndices(ctx.messages, index)
4917
4954
  })),
4918
4955
  diagnostics: toolDiagnostics(ctx.messages)
4919
4956
  };
4920
4957
  }
4958
+ function validateRemovalPlan(value, originalMessages, proposedMessages) {
4959
+ const errors = [];
4960
+ if (value === void 0) {
4961
+ error(
4962
+ errors,
4963
+ "/removals",
4964
+ "REMOVAL_PLAN_REQUIRED",
4965
+ "A removal plan is required for every context editor proposal."
4966
+ );
4967
+ return { errors };
4968
+ }
4969
+ if (!Array.isArray(value)) {
4970
+ error(errors, "/removals", "INVALID_REMOVALS", "removals must be an array.");
4971
+ return { errors };
4972
+ }
4973
+ if (value.length > MAX_REMOVAL_COUNT) {
4974
+ error(
4975
+ errors,
4976
+ "/removals",
4977
+ "TOO_MANY_REMOVALS",
4978
+ `removals must contain at most ${MAX_REMOVAL_COUNT} entries.`
4979
+ );
4980
+ return { errors };
4981
+ }
4982
+ const wholeMessages = /* @__PURE__ */ new Set();
4983
+ const touchedUsers = /* @__PURE__ */ new Set();
4984
+ const ranges = [];
4985
+ for (const [removalIndex, raw] of value.entries()) {
4986
+ const path30 = `/removals/${removalIndex}`;
4987
+ if (!isRecord3(raw) || !Number.isInteger(raw["messageIndex"])) {
4988
+ error(errors, path30, "INVALID_REMOVAL", "Removal must include an integer messageIndex.");
4989
+ continue;
4990
+ }
4991
+ const messageIndex = raw["messageIndex"];
4992
+ const original = originalMessages[messageIndex];
4993
+ if (!original) {
4994
+ error(errors, `${path30}/messageIndex`, "INVALID_MESSAGE_INDEX", "Removal messageIndex is out of range.");
4995
+ continue;
4996
+ }
4997
+ const start = raw["start"];
4998
+ const end = raw["end"];
4999
+ const blockIndex = raw["blockIndex"];
5000
+ if (start === void 0 && end === void 0 && blockIndex === void 0) {
5001
+ wholeMessages.add(messageIndex);
5002
+ if (original.role === "user") touchedUsers.add(messageIndex);
5003
+ continue;
5004
+ }
5005
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) {
5006
+ error(errors, path30, "INVALID_RANGE", "Range removal requires integer start/end with 0 <= start < end.");
5007
+ continue;
5008
+ }
5009
+ let text;
5010
+ if (blockIndex === void 0 && typeof original.content === "string") text = original.content;
5011
+ if (Number.isInteger(blockIndex) && Array.isArray(original.content)) {
5012
+ const block = original.content[blockIndex];
5013
+ if (block?.type === "text") text = block.text;
5014
+ }
5015
+ if (text === void 0 || end > text.length) {
5016
+ error(errors, path30, "INVALID_RANGE_TARGET", "Range must target existing string or text-block content.");
5017
+ continue;
5018
+ }
5019
+ if (splitsSurrogatePair(text, start) || splitsSurrogatePair(text, end)) {
5020
+ error(
5021
+ errors,
5022
+ path30,
5023
+ "INVALID_UNICODE_RANGE",
5024
+ "Range boundaries must not split a Unicode surrogate pair."
5025
+ );
5026
+ continue;
5027
+ }
5028
+ ranges.push({
5029
+ messageIndex,
5030
+ ...blockIndex === void 0 ? {} : { blockIndex },
5031
+ start,
5032
+ end
5033
+ });
5034
+ if (original.role === "user") touchedUsers.add(messageIndex);
5035
+ }
5036
+ const rangesByTarget = /* @__PURE__ */ new Map();
5037
+ for (const range of ranges) {
5038
+ const key = `${range.messageIndex}:${range.blockIndex ?? "string"}`;
5039
+ const targetRanges = rangesByTarget.get(key) ?? [];
5040
+ targetRanges.push(range);
5041
+ rangesByTarget.set(key, targetRanges);
5042
+ }
5043
+ for (const targetRanges of rangesByTarget.values()) {
5044
+ targetRanges.sort((left, right) => (left.start ?? 0) - (right.start ?? 0));
5045
+ for (let index = 1; index < targetRanges.length; index += 1) {
5046
+ const previous = targetRanges[index - 1];
5047
+ const current2 = targetRanges[index];
5048
+ if (previous?.end !== void 0 && current2?.start !== void 0 && current2.start < previous.end) {
5049
+ error(
5050
+ errors,
5051
+ "/removals",
5052
+ "OVERLAPPING_RANGES",
5053
+ "Removal ranges targeting the same text must not overlap."
5054
+ );
5055
+ break;
5056
+ }
5057
+ }
5058
+ }
5059
+ for (const userIndex of touchedUsers) {
5060
+ for (const assistantIndex of pairedAssistantIndices(originalMessages, userIndex)) {
5061
+ if (wholeMessages.has(assistantIndex)) continue;
5062
+ error(
5063
+ errors,
5064
+ "/removals",
5065
+ "MISSING_ASSISTANT_PAIR",
5066
+ `Editing user message ${userIndex} must also remove assistant message ${assistantIndex}.`
5067
+ );
5068
+ }
5069
+ }
5070
+ const expectedMessages = structuredClone(originalMessages);
5071
+ for (const targetRanges of rangesByTarget.values()) {
5072
+ const first = targetRanges[0];
5073
+ if (!first) continue;
5074
+ const message = expectedMessages[first.messageIndex];
5075
+ if (!message) continue;
5076
+ let text;
5077
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5078
+ text = message.content;
5079
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5080
+ const block = message.content[first.blockIndex];
5081
+ if (block?.type === "text") text = block.text;
5082
+ }
5083
+ if (text === void 0) continue;
5084
+ const pieces = [];
5085
+ let cursor = 0;
5086
+ for (const range of targetRanges) {
5087
+ if (range.start === void 0 || range.end === void 0) continue;
5088
+ pieces.push(text.slice(cursor, range.start));
5089
+ cursor = range.end;
5090
+ }
5091
+ pieces.push(text.slice(cursor));
5092
+ const nextText = pieces.join("");
5093
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5094
+ message.content = nextText;
5095
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5096
+ const block = message.content[first.blockIndex];
5097
+ if (block?.type === "text") block.text = nextText;
5098
+ }
5099
+ }
5100
+ const expectedProposal = expectedMessages.filter((_, index) => !wholeMessages.has(index));
5101
+ if (JSON.stringify(canonicalize(expectedProposal)) !== JSON.stringify(canonicalize(proposedMessages))) {
5102
+ error(
5103
+ errors,
5104
+ "/messages",
5105
+ "REMOVAL_PLAN_MISMATCH",
5106
+ "Submitted messages do not exactly match the declared removal plan."
5107
+ );
5108
+ }
5109
+ return errors.length > 0 ? { errors } : { errors, messages: expectedProposal };
5110
+ }
4921
5111
  function validateContextEditorProposal(input) {
4922
5112
  const currentRevision = contextEditorRevision(input.ctx.messages);
4923
5113
  const before = metricFor(input.ctx, input.ctx.messages, input.tools);
@@ -4969,7 +5159,23 @@ function validateContextEditorProposal(input) {
4969
5159
  repair: emptyRepair
4970
5160
  };
4971
5161
  }
4972
- const repaired = repairToolUseAdjacency(parsed.messages);
5162
+ const removalPlan = validateRemovalPlan(
5163
+ input.removals,
5164
+ input.ctx.messages,
5165
+ parsed.messages
5166
+ );
5167
+ if (removalPlan.errors.length > 0 || !removalPlan.messages) {
5168
+ return {
5169
+ ok: false,
5170
+ baseRevision: input.baseRevision,
5171
+ currentRevision,
5172
+ before,
5173
+ validationErrors: removalPlan.errors,
5174
+ warnings: [],
5175
+ repair: emptyRepair
5176
+ };
5177
+ }
5178
+ const repaired = repairToolUseAdjacency(removalPlan.messages);
4973
5179
  const repair = {
4974
5180
  changed: repaired.report.changed,
4975
5181
  removedToolUses: repaired.report.removedToolUses,
@@ -8485,19 +8691,22 @@ function extractToken(url) {
8485
8691
  function extractTokenFromCookie(cookieHeader) {
8486
8692
  if (!cookieHeader) return void 0;
8487
8693
  const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
8694
+ let plain;
8488
8695
  for (const part of raw.split(";")) {
8489
8696
  const eq = part.indexOf("=");
8490
8697
  if (eq < 0) continue;
8491
8698
  const name2 = part.slice(0, eq).trim();
8492
- if (name2 === "ws_token") {
8493
- try {
8494
- return decodeURIComponent(part.slice(eq + 1).trim());
8495
- } catch {
8496
- return part.slice(eq + 1).trim();
8497
- }
8699
+ if (name2 !== "ws_token" && name2 !== "__Host-ws_token") continue;
8700
+ let value;
8701
+ try {
8702
+ value = decodeURIComponent(part.slice(eq + 1).trim());
8703
+ } catch {
8704
+ value = part.slice(eq + 1).trim();
8498
8705
  }
8706
+ if (name2 === "__Host-ws_token") return value;
8707
+ plain ??= value;
8499
8708
  }
8500
- return void 0;
8709
+ return plain;
8501
8710
  }
8502
8711
  function hostHeaderOk(input) {
8503
8712
  if (!isLoopbackBind(input.wsHost)) return true;
@@ -8539,7 +8748,8 @@ function verifyClient(input) {
8539
8748
  expectedToken,
8540
8749
  requireToken,
8541
8750
  allowedHostnames,
8542
- allowBrowserUrlToken
8751
+ allowBrowserUrlToken,
8752
+ allowCrossPortLoopbackCookie
8543
8753
  } = input;
8544
8754
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
8545
8755
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
@@ -8554,7 +8764,10 @@ function verifyClient(input) {
8554
8764
  const { hostname: originHostname } = new URL(origin);
8555
8765
  if (isLoopbackHostname(originHostname)) {
8556
8766
  if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
8557
- return cookieTokenOk || isTrustedLoopbackOrigin(origin, hostHeader);
8767
+ if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
8768
+ return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
8769
+ }
8770
+ return true;
8558
8771
  }
8559
8772
  return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
8560
8773
  } catch {
@@ -8589,11 +8802,22 @@ ${out}`;
8589
8802
  function firstHeader(value) {
8590
8803
  return Array.isArray(value) ? value[0] : value;
8591
8804
  }
8592
- function wsTokenCookie(token) {
8593
- return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
8805
+ var WS_TOKEN_COOKIE = "ws_token";
8806
+ var WS_TOKEN_COOKIE_SECURE = "__Host-ws_token";
8807
+ function wsTokenCookie(token, secure) {
8808
+ const name2 = secure ? WS_TOKEN_COOKIE_SECURE : WS_TOKEN_COOKIE;
8809
+ const parts = [
8810
+ `${name2}=${encodeURIComponent(token)}`,
8811
+ "HttpOnly",
8812
+ "SameSite=Strict",
8813
+ "Path=/",
8814
+ "Max-Age=3600"
8815
+ ];
8816
+ if (secure) parts.push("Secure");
8817
+ return parts.join("; ");
8594
8818
  }
8595
- function setAuthCookieHeaders(res, token) {
8596
- res.setHeader("Set-Cookie", wsTokenCookie(token));
8819
+ function setAuthCookieHeaders(res, token, secure) {
8820
+ res.setHeader("Set-Cookie", wsTokenCookie(token, secure));
8597
8821
  res.setHeader("Cache-Control", "no-store");
8598
8822
  }
8599
8823
  function setStaticSecurityHeaders(res) {
@@ -8601,8 +8825,16 @@ function setStaticSecurityHeaders(res) {
8601
8825
  res.setHeader("X-Frame-Options", "DENY");
8602
8826
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
8603
8827
  }
8604
- function requestToken(req, url) {
8605
- return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
8828
+ function requestToken(req, url, opts = {}) {
8829
+ const queryToken = url.searchParams.get("token") ?? void 0;
8830
+ if (queryToken !== void 0 && (opts.allowQuery === true || isLoopbackPeer(req))) {
8831
+ return queryToken;
8832
+ }
8833
+ return firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
8834
+ }
8835
+ function isLoopbackPeer(req) {
8836
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
8837
+ return address !== void 0 && isLoopbackHostname(address);
8606
8838
  }
8607
8839
  function formatCspHostname(hostname) {
8608
8840
  return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -8657,7 +8889,8 @@ function strictDecodeParam(segment, res) {
8657
8889
  function createHttpServer(opts) {
8658
8890
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
8659
8891
  const distDir = path13.resolve(opts.distDir);
8660
- const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
8892
+ const requireAccessToken = true;
8893
+ const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
8661
8894
  const trustedHostnames = (() => {
8662
8895
  const names = [...opts.allowedHostnames ?? []];
8663
8896
  if (opts.publicWsUrl) {
@@ -8696,13 +8929,13 @@ function createHttpServer(opts) {
8696
8929
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
8697
8930
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
8698
8931
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
8699
- const provided = requestToken(req, url);
8932
+ const provided = requestToken(req, url, { allowQuery: true });
8700
8933
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
8701
8934
  res.writeHead(401, { "Content-Type": "text/plain" });
8702
8935
  res.end("Unauthorized");
8703
8936
  return;
8704
8937
  }
8705
- setAuthCookieHeaders(res, opts.apiToken);
8938
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
8706
8939
  res.writeHead(200, { "Content-Type": "text/plain" });
8707
8940
  res.end("ok");
8708
8941
  return;
@@ -8716,7 +8949,7 @@ function createHttpServer(opts) {
8716
8949
  return;
8717
8950
  }
8718
8951
  if (shouldSetAuthCookie && opts.apiToken) {
8719
- setAuthCookieHeaders(res, opts.apiToken);
8952
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
8720
8953
  }
8721
8954
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
8722
8955
  if (requireAccessToken && !accessTokenOk) {
@@ -11359,6 +11592,55 @@ import {
11359
11592
  restartMcp,
11360
11593
  updateMcp
11361
11594
  } from "@wrongstack/mcp";
11595
+
11596
+ // src/server/privileged-actions.ts
11597
+ import { randomUUID as randomUUID3 } from "node:crypto";
11598
+ import {
11599
+ isTrustDecisionAllowed
11600
+ } from "@wrongstack/core/security";
11601
+ async function authorizeWebUIAction(boundary, action, logger) {
11602
+ const request = {
11603
+ version: 1,
11604
+ requestId: randomUUID3(),
11605
+ actor: {
11606
+ kind: "remote-client",
11607
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
11608
+ },
11609
+ surface: "webui",
11610
+ capability: action.capability,
11611
+ subject: action.subject,
11612
+ risk: action.risk,
11613
+ scope: {
11614
+ ...action.cwd ? { cwd: action.cwd } : {},
11615
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
11616
+ },
11617
+ authContext: { method: "session" },
11618
+ ...action.metadata ? { metadata: action.metadata } : {}
11619
+ };
11620
+ const decision = await boundary.evaluate(request);
11621
+ logger?.debug?.(
11622
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
11623
+ );
11624
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
11625
+ }
11626
+
11627
+ // src/server/mcp-handlers.ts
11628
+ async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
11629
+ if (!trustBoundary) return true;
11630
+ const authorization = await authorizeWebUIAction(trustBoundary, {
11631
+ capability: "mcp.server.configure",
11632
+ subject: { kind: "process", id: serverName },
11633
+ risk: "elevated",
11634
+ metadata: { transport: "websocket", operation }
11635
+ });
11636
+ if (!authorization.allowed) {
11637
+ send(ws, {
11638
+ type: "mcp.operation_result",
11639
+ payload: { success: false, message: `${operation} denied: ${authorization.reason}` }
11640
+ });
11641
+ }
11642
+ return authorization.allowed;
11643
+ }
11362
11644
  function mapStatus(raw) {
11363
11645
  switch (raw) {
11364
11646
  case "connected":
@@ -11433,7 +11715,7 @@ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
11433
11715
  payload: { servers: servers.map((server) => toView(server, health.get(server.name))) }
11434
11716
  });
11435
11717
  }
11436
- async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11718
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11437
11719
  const d = deps(ws, globalConfigPath, mcpRegistry);
11438
11720
  if (!d) return;
11439
11721
  const validated = validateMcpServerPayload(msg.payload, "mcp.add");
@@ -11444,6 +11726,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11444
11726
  });
11445
11727
  return;
11446
11728
  }
11729
+ if (!await authorizeMcpMutation(ws, "mcp.add", name(msg), trustBoundary)) return;
11447
11730
  const result = await addMcp(validated.value, d);
11448
11731
  if (result.ok && result.server) {
11449
11732
  send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
@@ -11461,7 +11744,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11461
11744
  payload: { success: result.ok, message: result.message }
11462
11745
  });
11463
11746
  }
11464
- async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
11747
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11465
11748
  const d = deps(ws, globalConfigPath, mcpRegistry);
11466
11749
  if (!d) return;
11467
11750
  const validated = validateMcpServerPayload(msg.payload, "mcp.update");
@@ -11472,6 +11755,7 @@ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
11472
11755
  });
11473
11756
  return;
11474
11757
  }
11758
+ if (!await authorizeMcpMutation(ws, "mcp.update", name(msg), trustBoundary)) return;
11475
11759
  const result = await updateMcp(validated.value, d);
11476
11760
  if (result.ok && result.server) {
11477
11761
  send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
@@ -12572,11 +12856,11 @@ function createModelOperations(context) {
12572
12856
  }
12573
12857
 
12574
12858
  // src/server/port-utils.ts
12575
- import * as net2 from "node:net";
12859
+ import * as net from "node:net";
12576
12860
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
12577
12861
  function isPortFree(host, port) {
12578
12862
  return new Promise((resolve15) => {
12579
- const srv = net2.createServer();
12863
+ const srv = net.createServer();
12580
12864
  srv.once("error", () => resolve15(false));
12581
12865
  srv.once("listening", () => {
12582
12866
  srv.close(() => resolve15(true));
@@ -12821,6 +13105,8 @@ async function handleBrainAsk(ctx, ws, question) {
12821
13105
 
12822
13106
  // src/server/context-meta.ts
12823
13107
  import { FallbackProfileManager } from "@wrongstack/core/agent";
13108
+ import { resolvePluginEnablement } from "@wrongstack/core/plugin";
13109
+ import { FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
12824
13110
  function seedContextMeta(config, context) {
12825
13111
  const meta = context.meta;
12826
13112
  const autonomyCfg = config.autonomy ?? {};
@@ -12897,6 +13183,21 @@ function seedContextMeta(config, context) {
12897
13183
  meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
12898
13184
  const tgMs = tgExt?.["longToolThresholdMs"];
12899
13185
  meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
13186
+ {
13187
+ const pluginsEnabled = {};
13188
+ const record = (name2) => {
13189
+ if (FORBIDDEN_PROTO_KEYS2.has(name2) || name2 in pluginsEnabled) return;
13190
+ pluginsEnabled[name2] = resolvePluginEnablement({ name: name2, config }).enabled;
13191
+ };
13192
+ for (const entry of config.plugins ?? []) {
13193
+ const name2 = typeof entry === "string" ? entry : entry?.name;
13194
+ if (typeof name2 === "string") record(name2);
13195
+ }
13196
+ for (const [name2, options] of Object.entries(config.extensions ?? {})) {
13197
+ if (typeof options?.["enabled"] === "boolean") record(name2);
13198
+ }
13199
+ if (Object.keys(pluginsEnabled).length > 0) meta["pluginsEnabled"] = pluginsEnabled;
13200
+ }
12900
13201
  const chimeraExt = config.extensions?.["wstack-chimera"];
12901
13202
  meta["chimeraEnabled"] = chimeraExt?.["enabled"] === true;
12902
13203
  meta["chimeraProvider"] = chimeraExt?.["provider"] ?? "";
@@ -12934,8 +13235,9 @@ function seedContextMeta(config, context) {
12934
13235
  // src/server/pref-helpers.ts
12935
13236
  import * as fs13 from "node:fs/promises";
12936
13237
  import * as path15 from "node:path";
13238
+ import { pluginEntryMatchesName } from "@wrongstack/core/plugin";
12937
13239
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets } from "@wrongstack/core/security";
12938
- import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
13240
+ import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS3 } from "@wrongstack/core/utils";
12939
13241
  var PREF_KEYS = [
12940
13242
  "autonomy",
12941
13243
  "autonomyDelayMs",
@@ -13016,6 +13318,8 @@ var PREF_KEYS = [
13016
13318
  // Display-only toggles (purely visual WebUI prefs, not persisted to config).
13017
13319
  "groupToolCalls",
13018
13320
  "showThinkingLogs",
13321
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
13322
+ "autoCollapseInput",
13019
13323
  // Per-plugin enable/disable map (parity with the embedded server).
13020
13324
  "pluginsEnabled",
13021
13325
  // Fleet chat verbosity: off | full (migrated from streamFleet boolean).
@@ -13070,6 +13374,8 @@ async function updateGlobalConfig(deps2, holder, mutate, errorLabel) {
13070
13374
  var DISPLAY_ONLY_KEYS = /* @__PURE__ */ new Set([
13071
13375
  "groupToolCalls",
13072
13376
  "showThinkingLogs",
13377
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
13378
+ "autoCollapseInput",
13073
13379
  "autoReviewFallbackModels",
13074
13380
  // v11 Display parity: agent-swarm panel + inverse fsAccess flag.
13075
13381
  // The TUI settings picker mirrors these so the browser can keep the
@@ -13280,15 +13586,29 @@ async function persistPrefsToConfig(deps2, holder, payload) {
13280
13586
  decrypted.debugStream = payload["debugStream"];
13281
13587
  if (typeof payload["pluginsEnabled"] === "object" && payload["pluginsEnabled"] !== null) {
13282
13588
  const ext = decrypted.extensions ?? {};
13589
+ const toggled = [];
13283
13590
  for (const [pluginName, enabled] of Object.entries(
13284
13591
  payload["pluginsEnabled"]
13285
13592
  )) {
13286
- if (FORBIDDEN_PROTO_KEYS2.has(pluginName)) continue;
13593
+ if (FORBIDDEN_PROTO_KEYS3.has(pluginName)) continue;
13594
+ if (typeof enabled !== "boolean") continue;
13287
13595
  const pExt = ext[pluginName] ?? {};
13288
13596
  pExt["enabled"] = enabled;
13289
13597
  ext[pluginName] = pExt;
13598
+ toggled.push([pluginName, enabled]);
13290
13599
  }
13291
13600
  decrypted.extensions = ext;
13601
+ if (Array.isArray(decrypted.plugins) && toggled.length > 0) {
13602
+ decrypted.plugins = decrypted.plugins.map((entry) => {
13603
+ const entryName = typeof entry === "string" ? entry : entry?.name;
13604
+ if (typeof entryName !== "string") return entry;
13605
+ const hit = toggled.find(([name2]) => pluginEntryMatchesName(entryName, name2));
13606
+ if (!hit) return entry;
13607
+ const [, enabled] = hit;
13608
+ if (typeof entry === "string") return enabled ? entry : { name: entry, enabled: false };
13609
+ return { ...entry, enabled };
13610
+ });
13611
+ }
13292
13612
  }
13293
13613
  const chimeraTouched = typeof payload["chimeraEnabled"] === "boolean" || typeof payload["chimeraProvider"] === "string" || typeof payload["chimeraModel"] === "string" || typeof payload["chimeraMaxFiles"] === "number" || typeof payload["chimeraAutoFix"] === "string";
13294
13614
  if (chimeraTouched) {
@@ -13397,39 +13717,6 @@ async function handlePrefsRoute(ws, msg, handlers) {
13397
13717
  // src/server/process-handlers.ts
13398
13718
  import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
13399
13719
  import { getProcessRegistry as getProcessRegistry2 } from "@wrongstack/tools";
13400
-
13401
- // src/server/privileged-actions.ts
13402
- import { randomUUID as randomUUID3 } from "node:crypto";
13403
- import {
13404
- isTrustDecisionAllowed
13405
- } from "@wrongstack/core/security";
13406
- async function authorizeWebUIAction(boundary, action, logger) {
13407
- const request = {
13408
- version: 1,
13409
- requestId: randomUUID3(),
13410
- actor: {
13411
- kind: "remote-client",
13412
- ...action.sessionId ? { sessionId: action.sessionId } : {}
13413
- },
13414
- surface: "webui",
13415
- capability: action.capability,
13416
- subject: action.subject,
13417
- risk: action.risk,
13418
- scope: {
13419
- ...action.cwd ? { cwd: action.cwd } : {},
13420
- ...action.sessionId ? { sessionId: action.sessionId } : {}
13421
- },
13422
- authContext: { method: "session" },
13423
- ...action.metadata ? { metadata: action.metadata } : {}
13424
- };
13425
- const decision = await boundary.evaluate(request);
13426
- logger?.debug?.(
13427
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
13428
- );
13429
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
13430
- }
13431
-
13432
- // src/server/process-handlers.ts
13433
13720
  function handleProcessList(ws) {
13434
13721
  try {
13435
13722
  const procs = getProcessRegistry2().list();
@@ -14245,8 +14532,10 @@ function createProviderOperations(deps2) {
14245
14532
  if (result.ok) {
14246
14533
  deps2.log?.(`[WebUI] Provider "${payload.id}" added via provider.add`);
14247
14534
  }
14535
+ return result.ok;
14248
14536
  } catch (err) {
14249
14537
  sendOperationResult(ws, false, errMessage(err));
14538
+ return false;
14250
14539
  }
14251
14540
  }
14252
14541
  async function handleProviderRemove(ws, providerId) {
@@ -14567,6 +14856,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
14567
14856
  "completion.request",
14568
14857
  "model.switch",
14569
14858
  "model.refine",
14859
+ "model.fallback_choice",
14570
14860
  "autonomy.switch",
14571
14861
  "context.clear",
14572
14862
  "context.compact",
@@ -14838,6 +15128,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
14838
15128
  "provider.active_blocked",
14839
15129
  "provider.error",
14840
15130
  "provider.fallback",
15131
+ "provider.fallback_pending",
14841
15132
  "provider.response",
14842
15133
  "provider.retry",
14843
15134
  "provider.status_changed",
@@ -14879,6 +15170,7 @@ var SERVER_COLLABORATION_MESSAGE_TYPES = [
14879
15170
  "collab.state",
14880
15171
  "mailbox.action_result",
14881
15172
  "mailbox.agent_registered",
15173
+ "mailbox.agent_deregistered",
14882
15174
  "mailbox.agents",
14883
15175
  "mailbox.cleared",
14884
15176
  "mailbox.compacted",
@@ -15746,6 +16038,7 @@ function createSessionHandlers(ctx) {
15746
16038
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
15747
16039
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
15748
16040
  messages: payload["messages"],
16041
+ removals: payload["removals"],
15749
16042
  allowRepair: payload["allowRepair"] === true,
15750
16043
  runActive: ctx.isRunActive?.() === true
15751
16044
  });
@@ -15762,6 +16055,7 @@ function createSessionHandlers(ctx) {
15762
16055
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
15763
16056
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
15764
16057
  messages: payload["messages"],
16058
+ removals: payload["removals"],
15765
16059
  allowRepair: payload["allowRepair"] === true,
15766
16060
  runActive: ctx.isRunActive?.() === true
15767
16061
  });
@@ -16581,6 +16875,19 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
16581
16875
  return true;
16582
16876
  }
16583
16877
 
16878
+ // src/server/fallback-choice.ts
16879
+ function emitFallbackChoice(events, msg) {
16880
+ const parsed = validateModelFallbackChoicePayload(msg.payload);
16881
+ if (!parsed.ok) return parsed;
16882
+ events?.emit("provider.fallback_choice", {
16883
+ requestId: parsed.value.requestId,
16884
+ ...parsed.value.providerId ? { providerId: parsed.value.providerId } : {},
16885
+ ...parsed.value.model ? { model: parsed.value.model } : {},
16886
+ ...parsed.value.autoSwitch ? { autoSwitch: true } : {}
16887
+ });
16888
+ return { ok: true };
16889
+ }
16890
+
16584
16891
  // src/server/agent-roster-routes.ts
16585
16892
  async function handleAgentRosterRoute(ws, msg, handlers) {
16586
16893
  if (!msg.type.startsWith("agent-roster.")) return false;
@@ -16745,13 +17052,17 @@ async function handleProviderRoute(ws, msg, routes) {
16745
17052
  case "model.refine":
16746
17053
  await routes.refineModel(ws, msg);
16747
17054
  return true;
17055
+ case "model.fallback_choice":
17056
+ await routes.fallbackChoice(ws, msg);
17057
+ return true;
16748
17058
  case "key.add":
16749
17059
  case "key.update": {
16750
17060
  const payload = asPayloadRecord(msg);
16751
17061
  const providerId = payload ? requiredString(payload, "providerId") : null;
16752
17062
  const label = payload ? requiredString(payload, "label") : null;
16753
17063
  const apiKey = payload ? requiredString(payload, "apiKey") : null;
16754
- if (!providerId || !label || !apiKey) return invalidPayload(ws, msg.type);
17064
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label || !apiKey)
17065
+ return invalidPayload(ws, msg.type);
16755
17066
  await routes.providerHandlers.handleKeyUpsert(ws, providerId, label, apiKey);
16756
17067
  return true;
16757
17068
  }
@@ -16759,7 +17070,8 @@ async function handleProviderRoute(ws, msg, routes) {
16759
17070
  const payload = asPayloadRecord(msg);
16760
17071
  const providerId = payload ? requiredString(payload, "providerId") : null;
16761
17072
  const label = payload ? requiredString(payload, "label") : null;
16762
- if (!providerId || !label) return invalidPayload(ws, msg.type);
17073
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
17074
+ return invalidPayload(ws, msg.type);
16763
17075
  await routes.providerHandlers.handleKeyDelete(ws, providerId, label);
16764
17076
  return true;
16765
17077
  }
@@ -16767,7 +17079,8 @@ async function handleProviderRoute(ws, msg, routes) {
16767
17079
  const payload = asPayloadRecord(msg);
16768
17080
  const providerId = payload ? requiredString(payload, "providerId") : null;
16769
17081
  const label = payload ? requiredString(payload, "label") : null;
16770
- if (!providerId || !label) return invalidPayload(ws, msg.type);
17082
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
17083
+ return invalidPayload(ws, msg.type);
16771
17084
  await routes.providerHandlers.handleKeySetActive(ws, providerId, label);
16772
17085
  return true;
16773
17086
  }
@@ -16779,11 +17092,11 @@ async function handleProviderRoute(ws, msg, routes) {
16779
17092
  const apiKey = payload?.["apiKey"];
16780
17093
  const models = payload ? optionalStringArray(payload, "models") : null;
16781
17094
  const customModels = payload ? optionalCustomModels(payload) : null;
16782
- if (!id || !family) return invalidPayload(ws, msg.type);
17095
+ if (!id || !SAFE_CONFIG_KEY.test(id) || !family) return invalidPayload(ws, msg.type);
16783
17096
  if (baseUrl !== void 0 && typeof baseUrl !== "string") return invalidPayload(ws, msg.type);
16784
17097
  if (apiKey !== void 0 && typeof apiKey !== "string") return invalidPayload(ws, msg.type);
16785
17098
  if (models === null || customModels === null) return invalidPayload(ws, msg.type);
16786
- await routes.providerHandlers.handleProviderAdd(ws, {
17099
+ const added = await routes.providerHandlers.handleProviderAdd(ws, {
16787
17100
  id,
16788
17101
  family,
16789
17102
  baseUrl,
@@ -16791,20 +17104,22 @@ async function handleProviderRoute(ws, msg, routes) {
16791
17104
  models,
16792
17105
  customModels
16793
17106
  });
16794
- await routes.adoptDefaultProviderIfUnset(id);
17107
+ if (added) {
17108
+ void routes.adoptDefaultProviderIfUnset(id).catch(() => void 0);
17109
+ }
16795
17110
  return true;
16796
17111
  }
16797
17112
  case "provider.remove": {
16798
17113
  const payload = asPayloadRecord(msg);
16799
17114
  const providerId = payload ? requiredString(payload, "providerId") : null;
16800
- if (!providerId) return invalidPayload(ws, msg.type);
17115
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
16801
17116
  await routes.providerHandlers.handleProviderRemove(ws, providerId);
16802
17117
  return true;
16803
17118
  }
16804
17119
  case "provider.clear_models": {
16805
17120
  const payload = asPayloadRecord(msg);
16806
17121
  const providerId = payload ? requiredString(payload, "providerId") : null;
16807
- if (!providerId) return invalidPayload(ws, msg.type);
17122
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
16808
17123
  await routes.providerHandlers.handleProviderClearModels(ws, providerId);
16809
17124
  return true;
16810
17125
  }
@@ -16836,7 +17151,8 @@ async function handleProviderRoute(ws, msg, routes) {
16836
17151
  const payload = asPayloadRecord(msg);
16837
17152
  const providerId = payload ? requiredString(payload, "providerId") : null;
16838
17153
  const previousModels = payload ? optionalStringArray(payload, "previousModels") : null;
16839
- if (!providerId || !previousModels) return invalidPayload(ws, msg.type);
17154
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !previousModels)
17155
+ return invalidPayload(ws, msg.type);
16840
17156
  await routes.providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
16841
17157
  return true;
16842
17158
  }
@@ -16846,7 +17162,7 @@ async function handleProviderRoute(ws, msg, routes) {
16846
17162
  const envVars = payload ? optionalStringArray(payload, "envVars") : null;
16847
17163
  const models = payload ? optionalStringArray(payload, "models") : null;
16848
17164
  const customModels = payload ? optionalCustomModels(payload) : null;
16849
- if (!payload || !id || envVars === null || models === null || customModels === null)
17165
+ if (!payload || !id || !SAFE_CONFIG_KEY.test(id) || envVars === null || models === null || customModels === null)
16850
17166
  return invalidPayload(ws, msg.type);
16851
17167
  for (const key of ["family", "baseUrl"]) {
16852
17168
  if (payload[key] !== void 0 && typeof payload[key] !== "string")
@@ -16866,7 +17182,8 @@ async function handleProviderRoute(ws, msg, routes) {
16866
17182
  const payload = asPayloadRecord(msg);
16867
17183
  const providerId = payload ? requiredString(payload, "providerId") : null;
16868
17184
  const timeoutMs = payload ? optionalNumber(payload, "timeoutMs") : null;
16869
- if (!providerId || timeoutMs === null) return invalidPayload(ws, msg.type);
17185
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || timeoutMs === null)
17186
+ return invalidPayload(ws, msg.type);
16870
17187
  await routes.providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
16871
17188
  return true;
16872
17189
  }
@@ -16875,7 +17192,7 @@ async function handleProviderRoute(ws, msg, routes) {
16875
17192
  const kind = oauthKind(payload);
16876
17193
  const providerId = payload?.["providerId"];
16877
17194
  if (!kind) return invalidPayload(ws, msg.type);
16878
- if (providerId !== void 0 && typeof providerId !== "string") {
17195
+ if (providerId !== void 0 && (typeof providerId !== "string" || !SAFE_CONFIG_KEY.test(providerId))) {
16879
17196
  return invalidPayload(ws, msg.type);
16880
17197
  }
16881
17198
  await routes.providerHandlers.handleOAuthStart(ws, kind, providerId);
@@ -16914,7 +17231,8 @@ async function handleProviderRoute(ws, msg, routes) {
16914
17231
  const payload = asPayloadRecord(msg);
16915
17232
  const providerId = payload ? requiredString(payload, "providerId") : null;
16916
17233
  const model = payload ? requiredString(payload, "model") : null;
16917
- if (!providerId || !model) return invalidPayload(ws, msg.type);
17234
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
17235
+ return invalidPayload(ws, msg.type);
16918
17236
  const released = routes.statusTracker.retryNow(providerId, model);
16919
17237
  sendResult2(
16920
17238
  ws,
@@ -16931,7 +17249,8 @@ async function handleProviderRoute(ws, msg, routes) {
16931
17249
  const payload = asPayloadRecord(msg);
16932
17250
  const providerId = payload ? requiredString(payload, "providerId") : null;
16933
17251
  const model = payload ? requiredString(payload, "model") : null;
16934
- if (!providerId || !model) return invalidPayload(ws, msg.type);
17252
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
17253
+ return invalidPayload(ws, msg.type);
16935
17254
  routes.statusTracker.clear(providerId, model);
16936
17255
  sendResult2(ws, true, `Cleared tracking for ${providerId}/${model}.`);
16937
17256
  return true;
@@ -18961,7 +19280,22 @@ function setupEvents(deps2) {
18961
19280
  from: e.from,
18962
19281
  to: e.to,
18963
19282
  status: e.status,
18964
- providerSwitched: e.providerSwitched
19283
+ providerSwitched: e.providerSwitched,
19284
+ ...e.requestId ? { requestId: e.requestId } : {}
19285
+ })
19286
+ });
19287
+ });
19288
+ on("provider.fallback_pending", (e) => {
19289
+ broadcast2(clients, {
19290
+ type: "provider.fallback_pending",
19291
+ payload: sessionPayload2({
19292
+ sessionId: e.sessionId,
19293
+ from: e.from,
19294
+ status: e.status,
19295
+ candidates: e.candidates,
19296
+ autoSwitchSeconds: e.autoSwitchSeconds,
19297
+ requestId: e.requestId,
19298
+ timestamp: e.timestamp
18965
19299
  })
18966
19300
  });
18967
19301
  });
@@ -19046,6 +19380,15 @@ function setupEvents(deps2) {
19046
19380
  type: "mailbox.agent_registered",
19047
19381
  payload
19048
19382
  });
19383
+ }),
19384
+ // Deregistration (subagent retirement) must reach the browser too —
19385
+ // otherwise dead agents linger in the client roster until an unrelated
19386
+ // refresh. Emitted by sqlite-mailbox.deregisterAgent with { agentId }.
19387
+ events.onPattern("mailbox.agent_deregistered", (_e, payload) => {
19388
+ broadcast2(clients, {
19389
+ type: "mailbox.agent_deregistered",
19390
+ payload
19391
+ });
19049
19392
  })
19050
19393
  );
19051
19394
  const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: sessionPayload2({ kind, ...payload }) });
@@ -21330,29 +21673,10 @@ import { attachSessionKanbanMirror, hydrateSessionKanban } from "@wrongstack/too
21330
21673
  // src/server/model-auto-discovery.ts
21331
21674
  import * as fs19 from "node:fs/promises";
21332
21675
  import * as path24 from "node:path";
21333
- import { COMPATIBLE_PRESETS, discoverOpenAICompatibleModels } from "@wrongstack/providers";
21676
+ import { discoverOpenAICompatibleModels, resolveDiscoveryTargets } from "@wrongstack/providers";
21334
21677
  function isOverlayRegistry(value) {
21335
21678
  return !!value && typeof value === "object" && typeof value.mergeOverlay === "function";
21336
21679
  }
21337
- function resolveKey(cfg) {
21338
- if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
21339
- const active = cfg.activeKey ? cfg.apiKeys.find((key) => key.label === cfg.activeKey) : void 0;
21340
- return (active ?? cfg.apiKeys[0])?.apiKey;
21341
- }
21342
- return cfg.apiKey && cfg.apiKey.length > 0 ? cfg.apiKey : void 0;
21343
- }
21344
- function eligibleProviders(config) {
21345
- const out = [];
21346
- for (const [id, cfg] of Object.entries(config.providers ?? {})) {
21347
- const preset = COMPATIBLE_PRESETS[id];
21348
- const enabled = cfg.autoDiscoverModels ?? preset?.autoDiscover ?? false;
21349
- if (!enabled) continue;
21350
- const baseUrl = cfg.baseUrl ?? preset?.defaultBaseUrl;
21351
- if (!baseUrl) continue;
21352
- out.push({ id, cfg, baseUrl, apiKey: resolveKey(cfg) });
21353
- }
21354
- return out;
21355
- }
21356
21680
  async function readCache(file) {
21357
21681
  try {
21358
21682
  return JSON.parse(await fs19.readFile(file, "utf8"));
@@ -21363,14 +21687,13 @@ async function readCache(file) {
21363
21687
  async function discoverAndMergeWebuiProviders(opts) {
21364
21688
  const registry = opts.registry;
21365
21689
  if (!isOverlayRegistry(registry)) return;
21366
- const targets = eligibleProviders(opts.config);
21690
+ const targets = resolveDiscoveryTargets(opts.config);
21367
21691
  if (targets.length === 0) return;
21368
21692
  const cacheFile = path24.join(opts.cacheDir, "discovered-models-cache.json");
21369
21693
  const cache2 = await readCache(cacheFile);
21370
21694
  let cacheDirty = false;
21371
21695
  await Promise.all(
21372
- targets.map(async ({ id, cfg, baseUrl, apiKey }) => {
21373
- const cacheKey = `${id}\0${baseUrl}`;
21696
+ targets.map(async ({ id, cfg, baseUrl, apiKey, cacheKey }) => {
21374
21697
  const provider = await discoverOpenAICompatibleModels(id, {
21375
21698
  baseUrl,
21376
21699
  apiKey,
@@ -21736,15 +22059,6 @@ async function createPreContextServices(input) {
21736
22059
  logger.warn(`models.dev refresh failed (${toErrorMessage11(err)}); using cached catalog`);
21737
22060
  }
21738
22061
  }
21739
- try {
21740
- await installCatalogModelOutputLimits({
21741
- registry: modelsRegistry,
21742
- getConfig: () => config,
21743
- log: (message) => logger.debug(message)
21744
- });
21745
- } catch (err) {
21746
- logger.debug(`model output-limit index skipped: ${toErrorMessage11(err)}`);
21747
- }
21748
22062
  try {
21749
22063
  await discoverAndMergeWebuiProviders({
21750
22064
  config,
@@ -21755,6 +22069,15 @@ async function createPreContextServices(input) {
21755
22069
  } catch (err) {
21756
22070
  logger.debug(`provider auto-discovery skipped: ${toErrorMessage11(err)}`);
21757
22071
  }
22072
+ try {
22073
+ await installCatalogModelOutputLimits({
22074
+ registry: modelsRegistry,
22075
+ getConfig: () => config,
22076
+ log: (message) => logger.debug(message)
22077
+ });
22078
+ } catch (err) {
22079
+ logger.debug(`model output-limit index skipped: ${toErrorMessage11(err)}`);
22080
+ }
21758
22081
  const events = opts.services?.events ?? new EventBus();
21759
22082
  events.setLogger(logger);
21760
22083
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry, events });
@@ -22141,7 +22464,16 @@ function buildRoutes(state, deps2, cb) {
22141
22464
  refineModel: (ws, msg) => modelOperations.refineModel(
22142
22465
  ws,
22143
22466
  msg.payload
22144
- )
22467
+ ),
22468
+ fallbackChoice: async (ws, msg) => {
22469
+ const result = emitFallbackChoice(deps2.events, msg);
22470
+ if (!result.ok) {
22471
+ send(ws, {
22472
+ type: "error",
22473
+ payload: { phase: "invalid_request", message: result.message }
22474
+ });
22475
+ }
22476
+ }
22145
22477
  };
22146
22478
  const sessionRoutes = createSessionHandlers({
22147
22479
  config: state.getConfig(),
@@ -22319,8 +22651,10 @@ function buildRoutes(state, deps2, cb) {
22319
22651
  });
22320
22652
  const mcpRoutes = {
22321
22653
  list: (ws, msg) => handleMcpList(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22322
- add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22323
- update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22654
+ // add/update are the spawn-capable pair they take a `command`/`args`
22655
+ // from the wire and start it. They go past the trust boundary (M1).
22656
+ add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
22657
+ update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
22324
22658
  remove: (ws, msg) => handleMcpRemove(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22325
22659
  enable: (ws, msg) => handleMcpEnable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22326
22660
  disable: (ws, msg) => handleMcpDisable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
@@ -22492,7 +22826,10 @@ function createWsServers(httpServer, ports, accessToken) {
22492
22826
  expectedToken: wsToken,
22493
22827
  requireToken: ports.requireToken,
22494
22828
  allowedHostnames: publicHostnames,
22495
- allowBrowserUrlToken: Boolean(ports.publicWsUrl)
22829
+ allowBrowserUrlToken: Boolean(ports.publicWsUrl),
22830
+ // WS-003 opt-out for the Vite dev loop only (app and WS server cannot
22831
+ // share a port). Off unless explicitly requested — see ws-auth.ts.
22832
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
22496
22833
  });
22497
22834
  const WS_MAX_PAYLOAD = 20 * 1024 * 1024;
22498
22835
  const wssPrimary = new WebSocketServer({