@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.
package/dist/index.js CHANGED
@@ -73,6 +73,10 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
73
73
  // Display-only toggles (purely visual, persisted in localStorage via Zustand).
74
74
  "groupToolCalls",
75
75
  "showThinkingLogs",
76
+ // v15: auto-collapse of the chat input under the history (opt-in display
77
+ // toggle, default off). Whitelisted so the key survives `prefs.update`
78
+ // round-trips without tripping the "unknown preference key" rejection.
79
+ "autoCollapseInput",
76
80
  // v11 Display parity: inverse fsAccess flag.
77
81
  "allowOutsideProjectRoot",
78
82
  // v13 Display parity (TUI SettingsPicker fields 42 & 43): the read tool
@@ -397,6 +401,51 @@ function validateModelSwitchPayload(payload) {
397
401
  }
398
402
  };
399
403
  }
404
+ function validateModelFallbackChoicePayload(payload) {
405
+ if (!isRecord2(payload)) {
406
+ return {
407
+ ok: false,
408
+ message: "model.fallback_choice payload must be an object"
409
+ };
410
+ }
411
+ const requestId = payload["requestId"];
412
+ if (typeof requestId !== "string" || requestId.trim().length === 0) {
413
+ return {
414
+ ok: false,
415
+ message: "model.fallback_choice payload.requestId must be a non-empty string"
416
+ };
417
+ }
418
+ const providerId = payload["providerId"];
419
+ const model = payload["model"];
420
+ const autoSwitch = payload["autoSwitch"];
421
+ if (providerId !== void 0 && typeof providerId !== "string") {
422
+ return {
423
+ ok: false,
424
+ message: "model.fallback_choice payload.providerId must be a string when provided"
425
+ };
426
+ }
427
+ if (model !== void 0 && typeof model !== "string") {
428
+ return {
429
+ ok: false,
430
+ message: "model.fallback_choice payload.model must be a string when provided"
431
+ };
432
+ }
433
+ if (autoSwitch !== void 0 && typeof autoSwitch !== "boolean") {
434
+ return {
435
+ ok: false,
436
+ message: "model.fallback_choice payload.autoSwitch must be a boolean when provided"
437
+ };
438
+ }
439
+ return {
440
+ ok: true,
441
+ value: {
442
+ requestId: requestId.trim(),
443
+ ...typeof providerId === "string" ? { providerId } : {},
444
+ ...typeof model === "string" ? { model } : {},
445
+ ...typeof autoSwitch === "boolean" ? { autoSwitch } : {}
446
+ }
447
+ };
448
+ }
400
449
  var AUTONOMY_VALUES2 = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
401
450
  function validateMailboxMessagesPayload(payload) {
402
451
  if (payload === void 0) return { ok: true, value: void 0 };
@@ -4417,13 +4466,10 @@ function createConversationOperations(ctx) {
4417
4466
 
4418
4467
  // src/server/context-editor.ts
4419
4468
  import { createHash } from "node:crypto";
4420
- import net from "node:net";
4421
4469
  import {
4422
4470
  ALLOWED_IMAGE_MEDIA_TYPES,
4423
4471
  base64DecodedBytes,
4424
4472
  isAllowedImageMediaType,
4425
- isPrivateIPv4,
4426
- isPrivateIPv6,
4427
4473
  isValidImageBase64,
4428
4474
  MAX_INCOMING_IMAGE_BYTES,
4429
4475
  repairToolUseAdjacency
@@ -4491,40 +4537,7 @@ var REVISION_PREFIX = "wrongstack-context-editor-v1\0";
4491
4537
  var MAX_MESSAGE_COUNT_GROWTH = 10;
4492
4538
  var MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
4493
4539
  var MAX_STRING_LENGTH = 8 * 1024 * 1024;
4494
- var MAX_IMAGE_URL_LENGTH = 2048;
4495
- function imageUrlRejectionReason(url) {
4496
- if (url.length > MAX_IMAGE_URL_LENGTH) {
4497
- return `image.source.url exceeds ${MAX_IMAGE_URL_LENGTH} characters.`;
4498
- }
4499
- let parsed;
4500
- try {
4501
- parsed = new URL(url);
4502
- } catch {
4503
- return "image.source.url must be an absolute URL.";
4504
- }
4505
- if (parsed.protocol !== "https:") {
4506
- return `image.source.url must use https (got "${parsed.protocol}").`;
4507
- }
4508
- if (parsed.username !== "" || parsed.password !== "") {
4509
- return "image.source.url must not embed credentials.";
4510
- }
4511
- const host = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") ? parsed.hostname.slice(1, -1) : parsed.hostname;
4512
- const bareHost = host.endsWith(".") ? host.slice(0, -1) : host;
4513
- if (bareHost === "") {
4514
- return "image.source.url must include a hostname.";
4515
- }
4516
- if (bareHost === "localhost" || bareHost.endsWith(".localhost")) {
4517
- return "image.source.url must not target localhost.";
4518
- }
4519
- const family = net.isIP(bareHost);
4520
- if (family === 4 && isPrivateIPv4(bareHost)) {
4521
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4522
- }
4523
- if (family === 6 && isPrivateIPv6(bareHost)) {
4524
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4525
- }
4526
- return void 0;
4527
- }
4540
+ var MAX_REMOVAL_COUNT = 4096;
4528
4541
  function isRecord3(value) {
4529
4542
  return value !== null && typeof value === "object" && !Array.isArray(value);
4530
4543
  }
@@ -4533,7 +4546,7 @@ function canonicalize(value) {
4533
4546
  if (isRecord3(value)) {
4534
4547
  const sorted = {};
4535
4548
  for (const key of Object.keys(value).sort()) {
4536
- if (key === "_estTokens") continue;
4549
+ if (key === "_estTokens" || key === "_toolErrorInfo") continue;
4537
4550
  const item = value[key];
4538
4551
  if (item === void 0) continue;
4539
4552
  sorted[key] = canonicalize(item);
@@ -4564,6 +4577,12 @@ function isMessageRole(value) {
4564
4577
  function isPlainJsonObject(value) {
4565
4578
  return isRecord3(value);
4566
4579
  }
4580
+ function splitsSurrogatePair(text2, offset) {
4581
+ if (offset <= 0 || offset >= text2.length) return false;
4582
+ const previous = text2.charCodeAt(offset - 1);
4583
+ const next = text2.charCodeAt(offset);
4584
+ return previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343;
4585
+ }
4567
4586
  function validateCacheControl(value, path35, errors) {
4568
4587
  if (value === void 0) return void 0;
4569
4588
  if (!isRecord3(value) || value["type"] !== "ephemeral") {
@@ -4774,19 +4793,13 @@ function validateBlock(value, path35, errors) {
4774
4793
  );
4775
4794
  return void 0;
4776
4795
  }
4777
- const urlError = imageUrlRejectionReason(url);
4778
- if (urlError !== void 0) {
4779
- error(errors, `${path35}/source/url`, "UNSAFE_IMAGE_URL", urlError);
4780
- return void 0;
4781
- }
4782
- return {
4783
- type: "image",
4784
- source: {
4785
- type: "url",
4786
- ...typeof mediaType === "string" ? { media_type: mediaType } : {},
4787
- url
4788
- }
4789
- };
4796
+ error(
4797
+ errors,
4798
+ `${path35}/source/url`,
4799
+ "UNSAFE_IMAGE_URL",
4800
+ "URL image sources are not allowed in context editor proposals; use an ingested base64 image."
4801
+ );
4802
+ return void 0;
4790
4803
  }
4791
4804
  case "thinking": {
4792
4805
  const thinking = value["thinking"];
@@ -4943,6 +4956,14 @@ function warningsForMessage(message, index) {
4943
4956
  message: "This block contains provider replay metadata and should only be removed with the whole turn if no longer needed."
4944
4957
  });
4945
4958
  }
4959
+ if (block.type === "image" && block.source.type === "url") {
4960
+ warnings.push({
4961
+ path: `/messages/${index}/content/${blockIndex}/source/url`,
4962
+ code: "UNSAFE_IMAGE_URL",
4963
+ severity: "danger",
4964
+ message: "URL image sources cannot be retained in context editor proposals; remove the whole message before applying other edits."
4965
+ });
4966
+ }
4946
4967
  if (block.type === "tool_result" && block.content.length > 2e4) {
4947
4968
  warnings.push({
4948
4969
  path: `/messages/${index}/content/${blockIndex}`,
@@ -4970,6 +4991,21 @@ function metricFor(ctx, messages, tools) {
4970
4991
  fullRequestTokens: breakdown.total
4971
4992
  };
4972
4993
  }
4994
+ function isToolResultMessage(message) {
4995
+ return Boolean(
4996
+ message?.role === "user" && Array.isArray(message.content) && message.content.length > 0 && message.content.every((block) => block.type === "tool_result")
4997
+ );
4998
+ }
4999
+ function pairedAssistantIndices(messages, userIndex) {
5000
+ if (messages[userIndex]?.role !== "user" || isToolResultMessage(messages[userIndex])) return [];
5001
+ const paired = [];
5002
+ for (let index = userIndex + 1; index < messages.length; index += 1) {
5003
+ const message = messages[index];
5004
+ if (message?.role === "user" && !isToolResultMessage(message)) break;
5005
+ if (message?.role === "assistant") paired.push(index);
5006
+ }
5007
+ return paired;
5008
+ }
4973
5009
  function buildContextEditorSnapshot(ctx, tools) {
4974
5010
  const messages = ctx.messages.map(
4975
5011
  (message) => ({
@@ -4999,11 +5035,165 @@ function buildContextEditorSnapshot(ctx, tools) {
4999
5035
  tokens: messageTokens(message.content),
5000
5036
  preview: breakdown.messages.breakdown[index]?.preview ?? "",
5001
5037
  blockCount: Array.isArray(message.content) ? message.content.length : null,
5002
- warnings: warningsForMessage(message, index)
5038
+ warnings: warningsForMessage(message, index),
5039
+ pairedAssistantIndices: pairedAssistantIndices(ctx.messages, index)
5003
5040
  })),
5004
5041
  diagnostics: toolDiagnostics(ctx.messages)
5005
5042
  };
5006
5043
  }
5044
+ function validateRemovalPlan(value, originalMessages, proposedMessages) {
5045
+ const errors = [];
5046
+ if (value === void 0) {
5047
+ error(
5048
+ errors,
5049
+ "/removals",
5050
+ "REMOVAL_PLAN_REQUIRED",
5051
+ "A removal plan is required for every context editor proposal."
5052
+ );
5053
+ return { errors };
5054
+ }
5055
+ if (!Array.isArray(value)) {
5056
+ error(errors, "/removals", "INVALID_REMOVALS", "removals must be an array.");
5057
+ return { errors };
5058
+ }
5059
+ if (value.length > MAX_REMOVAL_COUNT) {
5060
+ error(
5061
+ errors,
5062
+ "/removals",
5063
+ "TOO_MANY_REMOVALS",
5064
+ `removals must contain at most ${MAX_REMOVAL_COUNT} entries.`
5065
+ );
5066
+ return { errors };
5067
+ }
5068
+ const wholeMessages = /* @__PURE__ */ new Set();
5069
+ const touchedUsers = /* @__PURE__ */ new Set();
5070
+ const ranges = [];
5071
+ for (const [removalIndex, raw] of value.entries()) {
5072
+ const path35 = `/removals/${removalIndex}`;
5073
+ if (!isRecord3(raw) || !Number.isInteger(raw["messageIndex"])) {
5074
+ error(errors, path35, "INVALID_REMOVAL", "Removal must include an integer messageIndex.");
5075
+ continue;
5076
+ }
5077
+ const messageIndex = raw["messageIndex"];
5078
+ const original = originalMessages[messageIndex];
5079
+ if (!original) {
5080
+ error(errors, `${path35}/messageIndex`, "INVALID_MESSAGE_INDEX", "Removal messageIndex is out of range.");
5081
+ continue;
5082
+ }
5083
+ const start = raw["start"];
5084
+ const end = raw["end"];
5085
+ const blockIndex = raw["blockIndex"];
5086
+ if (start === void 0 && end === void 0 && blockIndex === void 0) {
5087
+ wholeMessages.add(messageIndex);
5088
+ if (original.role === "user") touchedUsers.add(messageIndex);
5089
+ continue;
5090
+ }
5091
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) {
5092
+ error(errors, path35, "INVALID_RANGE", "Range removal requires integer start/end with 0 <= start < end.");
5093
+ continue;
5094
+ }
5095
+ let text2;
5096
+ if (blockIndex === void 0 && typeof original.content === "string") text2 = original.content;
5097
+ if (Number.isInteger(blockIndex) && Array.isArray(original.content)) {
5098
+ const block = original.content[blockIndex];
5099
+ if (block?.type === "text") text2 = block.text;
5100
+ }
5101
+ if (text2 === void 0 || end > text2.length) {
5102
+ error(errors, path35, "INVALID_RANGE_TARGET", "Range must target existing string or text-block content.");
5103
+ continue;
5104
+ }
5105
+ if (splitsSurrogatePair(text2, start) || splitsSurrogatePair(text2, end)) {
5106
+ error(
5107
+ errors,
5108
+ path35,
5109
+ "INVALID_UNICODE_RANGE",
5110
+ "Range boundaries must not split a Unicode surrogate pair."
5111
+ );
5112
+ continue;
5113
+ }
5114
+ ranges.push({
5115
+ messageIndex,
5116
+ ...blockIndex === void 0 ? {} : { blockIndex },
5117
+ start,
5118
+ end
5119
+ });
5120
+ if (original.role === "user") touchedUsers.add(messageIndex);
5121
+ }
5122
+ const rangesByTarget = /* @__PURE__ */ new Map();
5123
+ for (const range of ranges) {
5124
+ const key = `${range.messageIndex}:${range.blockIndex ?? "string"}`;
5125
+ const targetRanges = rangesByTarget.get(key) ?? [];
5126
+ targetRanges.push(range);
5127
+ rangesByTarget.set(key, targetRanges);
5128
+ }
5129
+ for (const targetRanges of rangesByTarget.values()) {
5130
+ targetRanges.sort((left, right) => (left.start ?? 0) - (right.start ?? 0));
5131
+ for (let index = 1; index < targetRanges.length; index += 1) {
5132
+ const previous = targetRanges[index - 1];
5133
+ const current2 = targetRanges[index];
5134
+ if (previous?.end !== void 0 && current2?.start !== void 0 && current2.start < previous.end) {
5135
+ error(
5136
+ errors,
5137
+ "/removals",
5138
+ "OVERLAPPING_RANGES",
5139
+ "Removal ranges targeting the same text must not overlap."
5140
+ );
5141
+ break;
5142
+ }
5143
+ }
5144
+ }
5145
+ for (const userIndex of touchedUsers) {
5146
+ for (const assistantIndex of pairedAssistantIndices(originalMessages, userIndex)) {
5147
+ if (wholeMessages.has(assistantIndex)) continue;
5148
+ error(
5149
+ errors,
5150
+ "/removals",
5151
+ "MISSING_ASSISTANT_PAIR",
5152
+ `Editing user message ${userIndex} must also remove assistant message ${assistantIndex}.`
5153
+ );
5154
+ }
5155
+ }
5156
+ const expectedMessages = structuredClone(originalMessages);
5157
+ for (const targetRanges of rangesByTarget.values()) {
5158
+ const first = targetRanges[0];
5159
+ if (!first) continue;
5160
+ const message = expectedMessages[first.messageIndex];
5161
+ if (!message) continue;
5162
+ let text2;
5163
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5164
+ text2 = message.content;
5165
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5166
+ const block = message.content[first.blockIndex];
5167
+ if (block?.type === "text") text2 = block.text;
5168
+ }
5169
+ if (text2 === void 0) continue;
5170
+ const pieces = [];
5171
+ let cursor = 0;
5172
+ for (const range of targetRanges) {
5173
+ if (range.start === void 0 || range.end === void 0) continue;
5174
+ pieces.push(text2.slice(cursor, range.start));
5175
+ cursor = range.end;
5176
+ }
5177
+ pieces.push(text2.slice(cursor));
5178
+ const nextText = pieces.join("");
5179
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5180
+ message.content = nextText;
5181
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5182
+ const block = message.content[first.blockIndex];
5183
+ if (block?.type === "text") block.text = nextText;
5184
+ }
5185
+ }
5186
+ const expectedProposal = expectedMessages.filter((_, index) => !wholeMessages.has(index));
5187
+ if (JSON.stringify(canonicalize(expectedProposal)) !== JSON.stringify(canonicalize(proposedMessages))) {
5188
+ error(
5189
+ errors,
5190
+ "/messages",
5191
+ "REMOVAL_PLAN_MISMATCH",
5192
+ "Submitted messages do not exactly match the declared removal plan."
5193
+ );
5194
+ }
5195
+ return errors.length > 0 ? { errors } : { errors, messages: expectedProposal };
5196
+ }
5007
5197
  function validateContextEditorProposal(input) {
5008
5198
  const currentRevision = contextEditorRevision(input.ctx.messages);
5009
5199
  const before = metricFor(input.ctx, input.ctx.messages, input.tools);
@@ -5055,7 +5245,23 @@ function validateContextEditorProposal(input) {
5055
5245
  repair: emptyRepair
5056
5246
  };
5057
5247
  }
5058
- const repaired = repairToolUseAdjacency(parsed.messages);
5248
+ const removalPlan = validateRemovalPlan(
5249
+ input.removals,
5250
+ input.ctx.messages,
5251
+ parsed.messages
5252
+ );
5253
+ if (removalPlan.errors.length > 0 || !removalPlan.messages) {
5254
+ return {
5255
+ ok: false,
5256
+ baseRevision: input.baseRevision,
5257
+ currentRevision,
5258
+ before,
5259
+ validationErrors: removalPlan.errors,
5260
+ warnings: [],
5261
+ repair: emptyRepair
5262
+ };
5263
+ }
5264
+ const repaired = repairToolUseAdjacency(removalPlan.messages);
5059
5265
  const repair = {
5060
5266
  changed: repaired.report.changed,
5061
5267
  removedToolUses: repaired.report.removedToolUses,
@@ -5305,7 +5511,7 @@ import {
5305
5511
  getKanbanServerConnection,
5306
5512
  isKanbanServerAvailable
5307
5513
  } from "@wrongstack/kanban";
5308
- import * as net2 from "node:net";
5514
+ import * as net from "node:net";
5309
5515
  import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5310
5516
  import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5311
5517
  import {
@@ -6149,7 +6355,7 @@ var RESTART_POLL_INTERVAL_MS = 250;
6149
6355
  var RESTART_DEADLINE_MS = 3e3;
6150
6356
  function isEndpointAlive(endpoint) {
6151
6357
  return new Promise((resolve16) => {
6152
- const sock = net2.createConnection(endpoint);
6358
+ const sock = net.createConnection(endpoint);
6153
6359
  const timer = setTimeout(() => {
6154
6360
  sock.destroy();
6155
6361
  resolve16(false);
@@ -9082,19 +9288,22 @@ function extractToken(url) {
9082
9288
  function extractTokenFromCookie(cookieHeader) {
9083
9289
  if (!cookieHeader) return void 0;
9084
9290
  const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
9291
+ let plain;
9085
9292
  for (const part of raw.split(";")) {
9086
9293
  const eq = part.indexOf("=");
9087
9294
  if (eq < 0) continue;
9088
9295
  const name2 = part.slice(0, eq).trim();
9089
- if (name2 === "ws_token") {
9090
- try {
9091
- return decodeURIComponent(part.slice(eq + 1).trim());
9092
- } catch {
9093
- return part.slice(eq + 1).trim();
9094
- }
9296
+ if (name2 !== "ws_token" && name2 !== "__Host-ws_token") continue;
9297
+ let value;
9298
+ try {
9299
+ value = decodeURIComponent(part.slice(eq + 1).trim());
9300
+ } catch {
9301
+ value = part.slice(eq + 1).trim();
9095
9302
  }
9303
+ if (name2 === "__Host-ws_token") return value;
9304
+ plain ??= value;
9096
9305
  }
9097
- return void 0;
9306
+ return plain;
9098
9307
  }
9099
9308
  function hostHeaderOk(input) {
9100
9309
  if (!isLoopbackBind(input.wsHost)) return true;
@@ -9136,7 +9345,8 @@ function verifyClient(input) {
9136
9345
  expectedToken,
9137
9346
  requireToken,
9138
9347
  allowedHostnames,
9139
- allowBrowserUrlToken
9348
+ allowBrowserUrlToken,
9349
+ allowCrossPortLoopbackCookie
9140
9350
  } = input;
9141
9351
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
9142
9352
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
@@ -9151,7 +9361,10 @@ function verifyClient(input) {
9151
9361
  const { hostname: originHostname } = new URL(origin);
9152
9362
  if (isLoopbackHostname(originHostname)) {
9153
9363
  if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
9154
- return cookieTokenOk || isTrustedLoopbackOrigin(origin, hostHeader);
9364
+ if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
9365
+ return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
9366
+ }
9367
+ return true;
9155
9368
  }
9156
9369
  return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
9157
9370
  } catch {
@@ -9186,11 +9399,22 @@ ${out}`;
9186
9399
  function firstHeader(value) {
9187
9400
  return Array.isArray(value) ? value[0] : value;
9188
9401
  }
9189
- function wsTokenCookie(token) {
9190
- return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
9402
+ var WS_TOKEN_COOKIE = "ws_token";
9403
+ var WS_TOKEN_COOKIE_SECURE = "__Host-ws_token";
9404
+ function wsTokenCookie(token, secure) {
9405
+ const name2 = secure ? WS_TOKEN_COOKIE_SECURE : WS_TOKEN_COOKIE;
9406
+ const parts = [
9407
+ `${name2}=${encodeURIComponent(token)}`,
9408
+ "HttpOnly",
9409
+ "SameSite=Strict",
9410
+ "Path=/",
9411
+ "Max-Age=3600"
9412
+ ];
9413
+ if (secure) parts.push("Secure");
9414
+ return parts.join("; ");
9191
9415
  }
9192
- function setAuthCookieHeaders(res, token) {
9193
- res.setHeader("Set-Cookie", wsTokenCookie(token));
9416
+ function setAuthCookieHeaders(res, token, secure) {
9417
+ res.setHeader("Set-Cookie", wsTokenCookie(token, secure));
9194
9418
  res.setHeader("Cache-Control", "no-store");
9195
9419
  }
9196
9420
  function setStaticSecurityHeaders(res) {
@@ -9198,8 +9422,16 @@ function setStaticSecurityHeaders(res) {
9198
9422
  res.setHeader("X-Frame-Options", "DENY");
9199
9423
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
9200
9424
  }
9201
- function requestToken(req, url) {
9202
- return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9425
+ function requestToken(req, url, opts = {}) {
9426
+ const queryToken = url.searchParams.get("token") ?? void 0;
9427
+ if (queryToken !== void 0 && (opts.allowQuery === true || isLoopbackPeer(req))) {
9428
+ return queryToken;
9429
+ }
9430
+ return firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9431
+ }
9432
+ function isLoopbackPeer(req) {
9433
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
9434
+ return address !== void 0 && isLoopbackHostname(address);
9203
9435
  }
9204
9436
  function formatCspHostname(hostname) {
9205
9437
  return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -9254,7 +9486,8 @@ function strictDecodeParam(segment, res) {
9254
9486
  function createHttpServer(opts) {
9255
9487
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
9256
9488
  const distDir = path13.resolve(opts.distDir);
9257
- const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
9489
+ const requireAccessToken = true;
9490
+ const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
9258
9491
  const trustedHostnames = (() => {
9259
9492
  const names = [...opts.allowedHostnames ?? []];
9260
9493
  if (opts.publicWsUrl) {
@@ -9293,13 +9526,13 @@ function createHttpServer(opts) {
9293
9526
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
9294
9527
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
9295
9528
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
9296
- const provided = requestToken(req, url);
9529
+ const provided = requestToken(req, url, { allowQuery: true });
9297
9530
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
9298
9531
  res.writeHead(401, { "Content-Type": "text/plain" });
9299
9532
  res.end("Unauthorized");
9300
9533
  return;
9301
9534
  }
9302
- setAuthCookieHeaders(res, opts.apiToken);
9535
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9303
9536
  res.writeHead(200, { "Content-Type": "text/plain" });
9304
9537
  res.end("ok");
9305
9538
  return;
@@ -9313,7 +9546,7 @@ function createHttpServer(opts) {
9313
9546
  return;
9314
9547
  }
9315
9548
  if (shouldSetAuthCookie && opts.apiToken) {
9316
- setAuthCookieHeaders(res, opts.apiToken);
9549
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9317
9550
  }
9318
9551
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
9319
9552
  if (requireAccessToken && !accessTokenOk) {
@@ -12029,6 +12262,55 @@ import {
12029
12262
  restartMcp,
12030
12263
  updateMcp
12031
12264
  } from "@wrongstack/mcp";
12265
+
12266
+ // src/server/privileged-actions.ts
12267
+ import { randomUUID as randomUUID3 } from "node:crypto";
12268
+ import {
12269
+ isTrustDecisionAllowed
12270
+ } from "@wrongstack/core/security";
12271
+ async function authorizeWebUIAction(boundary, action, logger) {
12272
+ const request = {
12273
+ version: 1,
12274
+ requestId: randomUUID3(),
12275
+ actor: {
12276
+ kind: "remote-client",
12277
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
12278
+ },
12279
+ surface: "webui",
12280
+ capability: action.capability,
12281
+ subject: action.subject,
12282
+ risk: action.risk,
12283
+ scope: {
12284
+ ...action.cwd ? { cwd: action.cwd } : {},
12285
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
12286
+ },
12287
+ authContext: { method: "session" },
12288
+ ...action.metadata ? { metadata: action.metadata } : {}
12289
+ };
12290
+ const decision = await boundary.evaluate(request);
12291
+ logger?.debug?.(
12292
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
12293
+ );
12294
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
12295
+ }
12296
+
12297
+ // src/server/mcp-handlers.ts
12298
+ async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12299
+ if (!trustBoundary) return true;
12300
+ const authorization = await authorizeWebUIAction(trustBoundary, {
12301
+ capability: "mcp.server.configure",
12302
+ subject: { kind: "process", id: serverName },
12303
+ risk: "elevated",
12304
+ metadata: { transport: "websocket", operation }
12305
+ });
12306
+ if (!authorization.allowed) {
12307
+ send(ws, {
12308
+ type: "mcp.operation_result",
12309
+ payload: { success: false, message: `${operation} denied: ${authorization.reason}` }
12310
+ });
12311
+ }
12312
+ return authorization.allowed;
12313
+ }
12032
12314
  function mapStatus(raw) {
12033
12315
  switch (raw) {
12034
12316
  case "connected":
@@ -12103,7 +12385,7 @@ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
12103
12385
  payload: { servers: servers.map((server) => toView(server, health.get(server.name))) }
12104
12386
  });
12105
12387
  }
12106
- async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12388
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
12107
12389
  const d = deps(ws, globalConfigPath, mcpRegistry);
12108
12390
  if (!d) return;
12109
12391
  const validated = validateMcpServerPayload(msg.payload, "mcp.add");
@@ -12114,6 +12396,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12114
12396
  });
12115
12397
  return;
12116
12398
  }
12399
+ if (!await authorizeMcpMutation(ws, "mcp.add", name(msg), trustBoundary)) return;
12117
12400
  const result = await addMcp(validated.value, d);
12118
12401
  if (result.ok && result.server) {
12119
12402
  send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
@@ -12131,7 +12414,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12131
12414
  payload: { success: result.ok, message: result.message }
12132
12415
  });
12133
12416
  }
12134
- async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12417
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
12135
12418
  const d = deps(ws, globalConfigPath, mcpRegistry);
12136
12419
  if (!d) return;
12137
12420
  const validated = validateMcpServerPayload(msg.payload, "mcp.update");
@@ -12142,6 +12425,7 @@ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12142
12425
  });
12143
12426
  return;
12144
12427
  }
12428
+ if (!await authorizeMcpMutation(ws, "mcp.update", name(msg), trustBoundary)) return;
12145
12429
  const result = await updateMcp(validated.value, d);
12146
12430
  if (result.ok && result.server) {
12147
12431
  send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
@@ -13285,7 +13569,7 @@ function openBrowser(url, platform = process.platform) {
13285
13569
  }
13286
13570
 
13287
13571
  // src/server/port-utils.ts
13288
- import * as net3 from "node:net";
13572
+ import * as net2 from "node:net";
13289
13573
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
13290
13574
  var SURFACE_DEFAULT_PORTS = {
13291
13575
  webui: { http: 3456 },
@@ -13299,7 +13583,7 @@ function getSurfaceDefaultPorts(surface) {
13299
13583
  }
13300
13584
  function isPortFree(host, port) {
13301
13585
  return new Promise((resolve16) => {
13302
- const srv = net3.createServer();
13586
+ const srv = net2.createServer();
13303
13587
  srv.once("error", () => resolve16(false));
13304
13588
  srv.once("listening", () => {
13305
13589
  srv.close(() => resolve16(true));
@@ -13560,7 +13844,8 @@ function registerWebuiInstance(p, deps2 = {}) {
13560
13844
  host: p.host,
13561
13845
  port: p.httpPort,
13562
13846
  publicUrl: p.publicUrl
13563
- })
13847
+ }),
13848
+ ...p.authToken ? { authToken: p.authToken } : {}
13564
13849
  },
13565
13850
  p.registryBaseDir
13566
13851
  ).catch(() => {
@@ -13594,10 +13879,31 @@ ${extraBlock}`
13594
13879
  if (p.open) launch(openUrl);
13595
13880
  });
13596
13881
  }
13882
+ var DEFAULT_CHILD_CLEANUP_TIMEOUT_MS = 1e4;
13883
+ async function runBounded(work, timeoutMs, label, debug) {
13884
+ let timer;
13885
+ try {
13886
+ await Promise.race([
13887
+ Promise.resolve().then(() => work()).catch((err) => {
13888
+ debug(`[webui-server] ${label} failed: ${err}`);
13889
+ }),
13890
+ new Promise((resolve16) => {
13891
+ timer = setTimeout(() => {
13892
+ debug(`[webui-server] ${label} timed out after ${timeoutMs}ms`);
13893
+ resolve16();
13894
+ }, timeoutMs);
13895
+ timer.unref?.();
13896
+ })
13897
+ ]);
13898
+ } finally {
13899
+ if (timer) clearTimeout(timer);
13900
+ }
13901
+ }
13597
13902
  function createWebuiShutdown(res) {
13598
13903
  const log = res.log ?? ((m) => console.log(m));
13599
13904
  const debug = res.debug ?? ((m) => console.debug(m));
13600
13905
  const unregister = res.unregisterFn ?? unregisterInstance;
13906
+ const childTimeout = Math.max(1, res.childCleanupTimeoutMs ?? DEFAULT_CHILD_CLEANUP_TIMEOUT_MS);
13601
13907
  let started = false;
13602
13908
  return () => {
13603
13909
  if (started) return;
@@ -13605,17 +13911,30 @@ function createWebuiShutdown(res) {
13605
13911
  log("[WebUI] Shutting down...");
13606
13912
  res.abortInFlight();
13607
13913
  res.unsubscribeEvents();
13608
- res.disposeResources?.();
13609
- res.closeClients();
13610
- const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13611
- (err) => debug(`[webui-server] unregister failed: ${err}`)
13612
- );
13613
- res.closeHttpServer();
13614
- res.wss.close(() => {
13615
- void unregistered.then(() => {
13616
- log("[WebUI] Server stopped");
13617
- res.onStopped();
13914
+ void (async () => {
13915
+ if (res.stopOwnedChildren) {
13916
+ await runBounded(res.stopOwnedChildren, childTimeout, "stopOwnedChildren", debug);
13917
+ }
13918
+ if (res.disposeResources) {
13919
+ await runBounded(res.disposeResources, Math.min(childTimeout, 5e3), "disposeResources", debug);
13920
+ }
13921
+ res.closeClients();
13922
+ res.closeHttpServer();
13923
+ const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13924
+ (err) => debug(`[webui-server] unregister failed: ${err}`)
13925
+ );
13926
+ await new Promise((resolve16) => {
13927
+ res.wss.close(() => resolve16());
13618
13928
  });
13929
+ await unregistered;
13930
+ log("[WebUI] Server stopped");
13931
+ res.onStopped();
13932
+ })().catch((err) => {
13933
+ debug(`[webui-server] shutdown sequence failed: ${err}`);
13934
+ try {
13935
+ res.onStopped();
13936
+ } catch {
13937
+ }
13619
13938
  });
13620
13939
  };
13621
13940
  }
@@ -14723,6 +15042,8 @@ function buildAuditPrompt(board, health) {
14723
15042
 
14724
15043
  // src/server/context-meta.ts
14725
15044
  import { FallbackProfileManager } from "@wrongstack/core/agent";
15045
+ import { resolvePluginEnablement } from "@wrongstack/core/plugin";
15046
+ import { FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
14726
15047
  function seedContextMeta(config, context) {
14727
15048
  const meta = context.meta;
14728
15049
  const autonomyCfg = config.autonomy ?? {};
@@ -14799,6 +15120,21 @@ function seedContextMeta(config, context) {
14799
15120
  meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
14800
15121
  const tgMs = tgExt?.["longToolThresholdMs"];
14801
15122
  meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
15123
+ {
15124
+ const pluginsEnabled = {};
15125
+ const record2 = (name2) => {
15126
+ if (FORBIDDEN_PROTO_KEYS2.has(name2) || name2 in pluginsEnabled) return;
15127
+ pluginsEnabled[name2] = resolvePluginEnablement({ name: name2, config }).enabled;
15128
+ };
15129
+ for (const entry of config.plugins ?? []) {
15130
+ const name2 = typeof entry === "string" ? entry : entry?.name;
15131
+ if (typeof name2 === "string") record2(name2);
15132
+ }
15133
+ for (const [name2, options] of Object.entries(config.extensions ?? {})) {
15134
+ if (typeof options?.["enabled"] === "boolean") record2(name2);
15135
+ }
15136
+ if (Object.keys(pluginsEnabled).length > 0) meta["pluginsEnabled"] = pluginsEnabled;
15137
+ }
14802
15138
  const chimeraExt = config.extensions?.["wstack-chimera"];
14803
15139
  meta["chimeraEnabled"] = chimeraExt?.["enabled"] === true;
14804
15140
  meta["chimeraProvider"] = chimeraExt?.["provider"] ?? "";
@@ -14836,8 +15172,9 @@ function seedContextMeta(config, context) {
14836
15172
  // src/server/pref-helpers.ts
14837
15173
  import * as fs13 from "node:fs/promises";
14838
15174
  import * as path18 from "node:path";
15175
+ import { pluginEntryMatchesName } from "@wrongstack/core/plugin";
14839
15176
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets } from "@wrongstack/core/security";
14840
- import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
15177
+ import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS3 } from "@wrongstack/core/utils";
14841
15178
  var PREF_KEYS = [
14842
15179
  "autonomy",
14843
15180
  "autonomyDelayMs",
@@ -14918,6 +15255,8 @@ var PREF_KEYS = [
14918
15255
  // Display-only toggles (purely visual WebUI prefs, not persisted to config).
14919
15256
  "groupToolCalls",
14920
15257
  "showThinkingLogs",
15258
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15259
+ "autoCollapseInput",
14921
15260
  // Per-plugin enable/disable map (parity with the embedded server).
14922
15261
  "pluginsEnabled",
14923
15262
  // Fleet chat verbosity: off | full (migrated from streamFleet boolean).
@@ -14972,6 +15311,8 @@ async function updateGlobalConfig(deps2, holder, mutate, errorLabel) {
14972
15311
  var DISPLAY_ONLY_KEYS = /* @__PURE__ */ new Set([
14973
15312
  "groupToolCalls",
14974
15313
  "showThinkingLogs",
15314
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15315
+ "autoCollapseInput",
14975
15316
  "autoReviewFallbackModels",
14976
15317
  // v11 Display parity: agent-swarm panel + inverse fsAccess flag.
14977
15318
  // The TUI settings picker mirrors these so the browser can keep the
@@ -15182,15 +15523,29 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15182
15523
  decrypted.debugStream = payload["debugStream"];
15183
15524
  if (typeof payload["pluginsEnabled"] === "object" && payload["pluginsEnabled"] !== null) {
15184
15525
  const ext = decrypted.extensions ?? {};
15526
+ const toggled = [];
15185
15527
  for (const [pluginName, enabled] of Object.entries(
15186
15528
  payload["pluginsEnabled"]
15187
15529
  )) {
15188
- if (FORBIDDEN_PROTO_KEYS2.has(pluginName)) continue;
15530
+ if (FORBIDDEN_PROTO_KEYS3.has(pluginName)) continue;
15531
+ if (typeof enabled !== "boolean") continue;
15189
15532
  const pExt = ext[pluginName] ?? {};
15190
15533
  pExt["enabled"] = enabled;
15191
15534
  ext[pluginName] = pExt;
15535
+ toggled.push([pluginName, enabled]);
15192
15536
  }
15193
15537
  decrypted.extensions = ext;
15538
+ if (Array.isArray(decrypted.plugins) && toggled.length > 0) {
15539
+ decrypted.plugins = decrypted.plugins.map((entry) => {
15540
+ const entryName = typeof entry === "string" ? entry : entry?.name;
15541
+ if (typeof entryName !== "string") return entry;
15542
+ const hit = toggled.find(([name2]) => pluginEntryMatchesName(entryName, name2));
15543
+ if (!hit) return entry;
15544
+ const [, enabled] = hit;
15545
+ if (typeof entry === "string") return enabled ? entry : { name: entry, enabled: false };
15546
+ return { ...entry, enabled };
15547
+ });
15548
+ }
15194
15549
  }
15195
15550
  const chimeraTouched = typeof payload["chimeraEnabled"] === "boolean" || typeof payload["chimeraProvider"] === "string" || typeof payload["chimeraModel"] === "string" || typeof payload["chimeraMaxFiles"] === "number" || typeof payload["chimeraAutoFix"] === "string";
15196
15551
  if (chimeraTouched) {
@@ -15299,39 +15654,6 @@ async function handlePrefsRoute(ws, msg, handlers) {
15299
15654
  // src/server/process-handlers.ts
15300
15655
  import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
15301
15656
  import { getProcessRegistry as getProcessRegistry2 } from "@wrongstack/tools";
15302
-
15303
- // src/server/privileged-actions.ts
15304
- import { randomUUID as randomUUID4 } from "node:crypto";
15305
- import {
15306
- isTrustDecisionAllowed
15307
- } from "@wrongstack/core/security";
15308
- async function authorizeWebUIAction(boundary, action, logger) {
15309
- const request = {
15310
- version: 1,
15311
- requestId: randomUUID4(),
15312
- actor: {
15313
- kind: "remote-client",
15314
- ...action.sessionId ? { sessionId: action.sessionId } : {}
15315
- },
15316
- surface: "webui",
15317
- capability: action.capability,
15318
- subject: action.subject,
15319
- risk: action.risk,
15320
- scope: {
15321
- ...action.cwd ? { cwd: action.cwd } : {},
15322
- ...action.sessionId ? { sessionId: action.sessionId } : {}
15323
- },
15324
- authContext: { method: "session" },
15325
- ...action.metadata ? { metadata: action.metadata } : {}
15326
- };
15327
- const decision = await boundary.evaluate(request);
15328
- logger?.debug?.(
15329
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
15330
- );
15331
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
15332
- }
15333
-
15334
- // src/server/process-handlers.ts
15335
15657
  function handleProcessList(ws) {
15336
15658
  try {
15337
15659
  const procs = getProcessRegistry2().list();
@@ -16155,8 +16477,10 @@ function createProviderOperations(deps2) {
16155
16477
  if (result.ok) {
16156
16478
  deps2.log?.(`[WebUI] Provider "${payload.id}" added via provider.add`);
16157
16479
  }
16480
+ return result.ok;
16158
16481
  } catch (err) {
16159
16482
  sendOperationResult(ws, false, errMessage(err));
16483
+ return false;
16160
16484
  }
16161
16485
  }
16162
16486
  async function handleProviderRemove(ws, providerId) {
@@ -16540,6 +16864,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16540
16864
  "completion.request",
16541
16865
  "model.switch",
16542
16866
  "model.refine",
16867
+ "model.fallback_choice",
16543
16868
  "autonomy.switch",
16544
16869
  "context.clear",
16545
16870
  "context.compact",
@@ -16811,6 +17136,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
16811
17136
  "provider.active_blocked",
16812
17137
  "provider.error",
16813
17138
  "provider.fallback",
17139
+ "provider.fallback_pending",
16814
17140
  "provider.response",
16815
17141
  "provider.retry",
16816
17142
  "provider.status_changed",
@@ -16852,6 +17178,7 @@ var SERVER_COLLABORATION_MESSAGE_TYPES = [
16852
17178
  "collab.state",
16853
17179
  "mailbox.action_result",
16854
17180
  "mailbox.agent_registered",
17181
+ "mailbox.agent_deregistered",
16855
17182
  "mailbox.agents",
16856
17183
  "mailbox.cleared",
16857
17184
  "mailbox.compacted",
@@ -17231,6 +17558,13 @@ function projectToolMessage(message) {
17231
17558
  }
17232
17559
  return null;
17233
17560
  }
17561
+ function optionalFinite(value) {
17562
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
17563
+ return value;
17564
+ }
17565
+ function optionalString2(value) {
17566
+ return typeof value === "string" && value.length > 0 ? value : void 0;
17567
+ }
17234
17568
  function projectFleetMessage(message) {
17235
17569
  const payload = record(message.payload);
17236
17570
  if (!payload) return null;
@@ -17239,7 +17573,15 @@ function projectFleetMessage(message) {
17239
17573
  return {
17240
17574
  kind: "concurrency",
17241
17575
  active: finite(payload["fleetConcurrency"]),
17242
- maximum: finite(payload["fleetConcurrencyMax"])
17576
+ maximum: finite(payload["fleetConcurrencyMax"]),
17577
+ maxSpawns: optionalFinite(payload["maxSpawns"]),
17578
+ usedSpawns: optionalFinite(payload["usedSpawns"]),
17579
+ remainingSpawns: optionalFinite(payload["remainingSpawns"]),
17580
+ maxSpawnsSource: optionalString2(payload["maxSpawnsSource"]),
17581
+ maxConcurrentSource: optionalString2(payload["maxConcurrentSource"]),
17582
+ effectiveSource: optionalString2(payload["effectiveSource"]),
17583
+ checkpointMaxSpawns: optionalFinite(payload["checkpointMaxSpawns"]),
17584
+ ceilingMismatch: payload["ceilingMismatch"] === true ? true : void 0
17243
17585
  };
17244
17586
  case "client.status_update":
17245
17587
  return { kind: "client-status", status: payload };
@@ -17895,6 +18237,7 @@ function createSessionHandlers(ctx) {
17895
18237
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17896
18238
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17897
18239
  messages: payload["messages"],
18240
+ removals: payload["removals"],
17898
18241
  allowRepair: payload["allowRepair"] === true,
17899
18242
  runActive: ctx.isRunActive?.() === true
17900
18243
  });
@@ -17911,6 +18254,7 @@ function createSessionHandlers(ctx) {
17911
18254
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17912
18255
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17913
18256
  messages: payload["messages"],
18257
+ removals: payload["removals"],
17914
18258
  allowRepair: payload["allowRepair"] === true,
17915
18259
  runActive: ctx.isRunActive?.() === true
17916
18260
  });
@@ -18892,6 +19236,19 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
18892
19236
  return true;
18893
19237
  }
18894
19238
 
19239
+ // src/server/fallback-choice.ts
19240
+ function emitFallbackChoice(events, msg) {
19241
+ const parsed = validateModelFallbackChoicePayload(msg.payload);
19242
+ if (!parsed.ok) return parsed;
19243
+ events?.emit("provider.fallback_choice", {
19244
+ requestId: parsed.value.requestId,
19245
+ ...parsed.value.providerId ? { providerId: parsed.value.providerId } : {},
19246
+ ...parsed.value.model ? { model: parsed.value.model } : {},
19247
+ ...parsed.value.autoSwitch ? { autoSwitch: true } : {}
19248
+ });
19249
+ return { ok: true };
19250
+ }
19251
+
18895
19252
  // src/server/agent-roster-routes.ts
18896
19253
  async function handleAgentRosterRoute(ws, msg, handlers) {
18897
19254
  if (!msg.type.startsWith("agent-roster.")) return false;
@@ -19056,13 +19413,17 @@ async function handleProviderRoute(ws, msg, routes) {
19056
19413
  case "model.refine":
19057
19414
  await routes.refineModel(ws, msg);
19058
19415
  return true;
19416
+ case "model.fallback_choice":
19417
+ await routes.fallbackChoice(ws, msg);
19418
+ return true;
19059
19419
  case "key.add":
19060
19420
  case "key.update": {
19061
19421
  const payload = asPayloadRecord(msg);
19062
19422
  const providerId = payload ? requiredString(payload, "providerId") : null;
19063
19423
  const label = payload ? requiredString(payload, "label") : null;
19064
19424
  const apiKey = payload ? requiredString(payload, "apiKey") : null;
19065
- if (!providerId || !label || !apiKey) return invalidPayload(ws, msg.type);
19425
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label || !apiKey)
19426
+ return invalidPayload(ws, msg.type);
19066
19427
  await routes.providerHandlers.handleKeyUpsert(ws, providerId, label, apiKey);
19067
19428
  return true;
19068
19429
  }
@@ -19070,7 +19431,8 @@ async function handleProviderRoute(ws, msg, routes) {
19070
19431
  const payload = asPayloadRecord(msg);
19071
19432
  const providerId = payload ? requiredString(payload, "providerId") : null;
19072
19433
  const label = payload ? requiredString(payload, "label") : null;
19073
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19434
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19435
+ return invalidPayload(ws, msg.type);
19074
19436
  await routes.providerHandlers.handleKeyDelete(ws, providerId, label);
19075
19437
  return true;
19076
19438
  }
@@ -19078,7 +19440,8 @@ async function handleProviderRoute(ws, msg, routes) {
19078
19440
  const payload = asPayloadRecord(msg);
19079
19441
  const providerId = payload ? requiredString(payload, "providerId") : null;
19080
19442
  const label = payload ? requiredString(payload, "label") : null;
19081
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19443
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19444
+ return invalidPayload(ws, msg.type);
19082
19445
  await routes.providerHandlers.handleKeySetActive(ws, providerId, label);
19083
19446
  return true;
19084
19447
  }
@@ -19090,11 +19453,11 @@ async function handleProviderRoute(ws, msg, routes) {
19090
19453
  const apiKey = payload?.["apiKey"];
19091
19454
  const models = payload ? optionalStringArray(payload, "models") : null;
19092
19455
  const customModels = payload ? optionalCustomModels(payload) : null;
19093
- if (!id || !family) return invalidPayload(ws, msg.type);
19456
+ if (!id || !SAFE_CONFIG_KEY.test(id) || !family) return invalidPayload(ws, msg.type);
19094
19457
  if (baseUrl !== void 0 && typeof baseUrl !== "string") return invalidPayload(ws, msg.type);
19095
19458
  if (apiKey !== void 0 && typeof apiKey !== "string") return invalidPayload(ws, msg.type);
19096
19459
  if (models === null || customModels === null) return invalidPayload(ws, msg.type);
19097
- await routes.providerHandlers.handleProviderAdd(ws, {
19460
+ const added = await routes.providerHandlers.handleProviderAdd(ws, {
19098
19461
  id,
19099
19462
  family,
19100
19463
  baseUrl,
@@ -19102,20 +19465,22 @@ async function handleProviderRoute(ws, msg, routes) {
19102
19465
  models,
19103
19466
  customModels
19104
19467
  });
19105
- await routes.adoptDefaultProviderIfUnset(id);
19468
+ if (added) {
19469
+ void routes.adoptDefaultProviderIfUnset(id).catch(() => void 0);
19470
+ }
19106
19471
  return true;
19107
19472
  }
19108
19473
  case "provider.remove": {
19109
19474
  const payload = asPayloadRecord(msg);
19110
19475
  const providerId = payload ? requiredString(payload, "providerId") : null;
19111
- if (!providerId) return invalidPayload(ws, msg.type);
19476
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
19112
19477
  await routes.providerHandlers.handleProviderRemove(ws, providerId);
19113
19478
  return true;
19114
19479
  }
19115
19480
  case "provider.clear_models": {
19116
19481
  const payload = asPayloadRecord(msg);
19117
19482
  const providerId = payload ? requiredString(payload, "providerId") : null;
19118
- if (!providerId) return invalidPayload(ws, msg.type);
19483
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
19119
19484
  await routes.providerHandlers.handleProviderClearModels(ws, providerId);
19120
19485
  return true;
19121
19486
  }
@@ -19147,7 +19512,8 @@ async function handleProviderRoute(ws, msg, routes) {
19147
19512
  const payload = asPayloadRecord(msg);
19148
19513
  const providerId = payload ? requiredString(payload, "providerId") : null;
19149
19514
  const previousModels = payload ? optionalStringArray(payload, "previousModels") : null;
19150
- if (!providerId || !previousModels) return invalidPayload(ws, msg.type);
19515
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !previousModels)
19516
+ return invalidPayload(ws, msg.type);
19151
19517
  await routes.providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
19152
19518
  return true;
19153
19519
  }
@@ -19157,7 +19523,7 @@ async function handleProviderRoute(ws, msg, routes) {
19157
19523
  const envVars = payload ? optionalStringArray(payload, "envVars") : null;
19158
19524
  const models = payload ? optionalStringArray(payload, "models") : null;
19159
19525
  const customModels = payload ? optionalCustomModels(payload) : null;
19160
- if (!payload || !id || envVars === null || models === null || customModels === null)
19526
+ if (!payload || !id || !SAFE_CONFIG_KEY.test(id) || envVars === null || models === null || customModels === null)
19161
19527
  return invalidPayload(ws, msg.type);
19162
19528
  for (const key of ["family", "baseUrl"]) {
19163
19529
  if (payload[key] !== void 0 && typeof payload[key] !== "string")
@@ -19177,7 +19543,8 @@ async function handleProviderRoute(ws, msg, routes) {
19177
19543
  const payload = asPayloadRecord(msg);
19178
19544
  const providerId = payload ? requiredString(payload, "providerId") : null;
19179
19545
  const timeoutMs = payload ? optionalNumber(payload, "timeoutMs") : null;
19180
- if (!providerId || timeoutMs === null) return invalidPayload(ws, msg.type);
19546
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || timeoutMs === null)
19547
+ return invalidPayload(ws, msg.type);
19181
19548
  await routes.providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
19182
19549
  return true;
19183
19550
  }
@@ -19186,7 +19553,7 @@ async function handleProviderRoute(ws, msg, routes) {
19186
19553
  const kind = oauthKind(payload);
19187
19554
  const providerId = payload?.["providerId"];
19188
19555
  if (!kind) return invalidPayload(ws, msg.type);
19189
- if (providerId !== void 0 && typeof providerId !== "string") {
19556
+ if (providerId !== void 0 && (typeof providerId !== "string" || !SAFE_CONFIG_KEY.test(providerId))) {
19190
19557
  return invalidPayload(ws, msg.type);
19191
19558
  }
19192
19559
  await routes.providerHandlers.handleOAuthStart(ws, kind, providerId);
@@ -19225,7 +19592,8 @@ async function handleProviderRoute(ws, msg, routes) {
19225
19592
  const payload = asPayloadRecord(msg);
19226
19593
  const providerId = payload ? requiredString(payload, "providerId") : null;
19227
19594
  const model = payload ? requiredString(payload, "model") : null;
19228
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19595
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19596
+ return invalidPayload(ws, msg.type);
19229
19597
  const released = routes.statusTracker.retryNow(providerId, model);
19230
19598
  sendResult2(
19231
19599
  ws,
@@ -19242,7 +19610,8 @@ async function handleProviderRoute(ws, msg, routes) {
19242
19610
  const payload = asPayloadRecord(msg);
19243
19611
  const providerId = payload ? requiredString(payload, "providerId") : null;
19244
19612
  const model = payload ? requiredString(payload, "model") : null;
19245
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19613
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19614
+ return invalidPayload(ws, msg.type);
19246
19615
  routes.statusTracker.clear(providerId, model);
19247
19616
  sendResult2(ws, true, `Cleared tracking for ${providerId}/${model}.`);
19248
19617
  return true;
@@ -19600,8 +19969,10 @@ function createEmbeddedMessageRouter(deps2) {
19600
19969
  };
19601
19970
  const mcp = {
19602
19971
  list: (ws, msg) => handleMcpList(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19603
- add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19604
- update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19972
+ // add/update are the spawn-capable pair they take a `command`/`args`
19973
+ // from the wire and start it. They go past the trust boundary (M1).
19974
+ add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
19975
+ update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
19605
19976
  remove: (ws, msg) => handleMcpRemove(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19606
19977
  enable: (ws, msg) => handleMcpEnable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19607
19978
  disable: (ws, msg) => handleMcpDisable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
@@ -19688,6 +20059,15 @@ function createEmbeddedMessageRouter(deps2) {
19688
20059
  searchProviderModels: (ws, query, limit) => providerOperations.handleProviderModelsSearch(ws, query, limit),
19689
20060
  switchModel: (ws, msg) => modelOperations.switchModel(ws, msg.payload),
19690
20061
  refineModel: (ws, msg) => modelOperations.refineModel(ws, msg.payload),
20062
+ fallbackChoice: async (ws, msg) => {
20063
+ const result = emitFallbackChoice(deps2.sessionCtx.opts.events, msg);
20064
+ if (!result.ok) {
20065
+ send2(ws, {
20066
+ type: "error",
20067
+ payload: { phase: "invalid_request", message: result.message }
20068
+ });
20069
+ }
20070
+ },
19691
20071
  adoptDefaultProviderIfUnset: providerOperations.adoptDefaultProviderIfUnset,
19692
20072
  providerHandlers: providerOperations,
19693
20073
  statusTracker: deps2.statusTracker
@@ -19981,17 +20361,12 @@ function createProviderStore(deps2) {
19981
20361
  cfg.activeKey = active.label;
19982
20362
  }
19983
20363
  }
19984
- function maskedKey2(key) {
19985
- if (!key) return "\u2014";
19986
- if (key.length <= 8) return "\u2022".repeat(key.length);
19987
- return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
19988
- }
19989
20364
  return {
19990
20365
  load: loadSavedProviders2,
19991
20366
  save: saveProviders2,
19992
20367
  normalizeKeys: normalizeKeys2,
19993
20368
  writeKeysBack: writeKeysBack2,
19994
- maskedKey: maskedKey2
20369
+ maskedKey
19995
20370
  };
19996
20371
  }
19997
20372
 
@@ -21732,7 +22107,22 @@ function setupEvents(deps2) {
21732
22107
  from: e.from,
21733
22108
  to: e.to,
21734
22109
  status: e.status,
21735
- providerSwitched: e.providerSwitched
22110
+ providerSwitched: e.providerSwitched,
22111
+ ...e.requestId ? { requestId: e.requestId } : {}
22112
+ })
22113
+ });
22114
+ });
22115
+ on("provider.fallback_pending", (e) => {
22116
+ broadcast2(clients, {
22117
+ type: "provider.fallback_pending",
22118
+ payload: sessionPayload2({
22119
+ sessionId: e.sessionId,
22120
+ from: e.from,
22121
+ status: e.status,
22122
+ candidates: e.candidates,
22123
+ autoSwitchSeconds: e.autoSwitchSeconds,
22124
+ requestId: e.requestId,
22125
+ timestamp: e.timestamp
21736
22126
  })
21737
22127
  });
21738
22128
  });
@@ -21817,6 +22207,15 @@ function setupEvents(deps2) {
21817
22207
  type: "mailbox.agent_registered",
21818
22208
  payload
21819
22209
  });
22210
+ }),
22211
+ // Deregistration (subagent retirement) must reach the browser too —
22212
+ // otherwise dead agents linger in the client roster until an unrelated
22213
+ // refresh. Emitted by sqlite-mailbox.deregisterAgent with { agentId }.
22214
+ events.onPattern("mailbox.agent_deregistered", (_e, payload) => {
22215
+ broadcast2(clients, {
22216
+ type: "mailbox.agent_deregistered",
22217
+ payload
22218
+ });
21820
22219
  })
21821
22220
  );
21822
22221
  const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: sessionPayload2({ kind, ...payload }) });
@@ -24101,29 +24500,10 @@ import { attachSessionKanbanMirror, hydrateSessionKanban } from "@wrongstack/too
24101
24500
  // src/server/model-auto-discovery.ts
24102
24501
  import * as fs21 from "node:fs/promises";
24103
24502
  import * as path29 from "node:path";
24104
- import { COMPATIBLE_PRESETS, discoverOpenAICompatibleModels } from "@wrongstack/providers";
24503
+ import { discoverOpenAICompatibleModels, resolveDiscoveryTargets } from "@wrongstack/providers";
24105
24504
  function isOverlayRegistry(value) {
24106
24505
  return !!value && typeof value === "object" && typeof value.mergeOverlay === "function";
24107
24506
  }
24108
- function resolveKey(cfg) {
24109
- if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
24110
- const active = cfg.activeKey ? cfg.apiKeys.find((key) => key.label === cfg.activeKey) : void 0;
24111
- return (active ?? cfg.apiKeys[0])?.apiKey;
24112
- }
24113
- return cfg.apiKey && cfg.apiKey.length > 0 ? cfg.apiKey : void 0;
24114
- }
24115
- function eligibleProviders(config) {
24116
- const out = [];
24117
- for (const [id, cfg] of Object.entries(config.providers ?? {})) {
24118
- const preset = COMPATIBLE_PRESETS[id];
24119
- const enabled = cfg.autoDiscoverModels ?? preset?.autoDiscover ?? false;
24120
- if (!enabled) continue;
24121
- const baseUrl = cfg.baseUrl ?? preset?.defaultBaseUrl;
24122
- if (!baseUrl) continue;
24123
- out.push({ id, cfg, baseUrl, apiKey: resolveKey(cfg) });
24124
- }
24125
- return out;
24126
- }
24127
24507
  async function readCache(file) {
24128
24508
  try {
24129
24509
  return JSON.parse(await fs21.readFile(file, "utf8"));
@@ -24134,14 +24514,13 @@ async function readCache(file) {
24134
24514
  async function discoverAndMergeWebuiProviders(opts) {
24135
24515
  const registry = opts.registry;
24136
24516
  if (!isOverlayRegistry(registry)) return;
24137
- const targets = eligibleProviders(opts.config);
24517
+ const targets = resolveDiscoveryTargets(opts.config);
24138
24518
  if (targets.length === 0) return;
24139
24519
  const cacheFile = path29.join(opts.cacheDir, "discovered-models-cache.json");
24140
24520
  const cache2 = await readCache(cacheFile);
24141
24521
  let cacheDirty = false;
24142
24522
  await Promise.all(
24143
- targets.map(async ({ id, cfg, baseUrl, apiKey }) => {
24144
- const cacheKey = `${id}\0${baseUrl}`;
24523
+ targets.map(async ({ id, cfg, baseUrl, apiKey, cacheKey }) => {
24145
24524
  const provider = await discoverOpenAICompatibleModels(id, {
24146
24525
  baseUrl,
24147
24526
  apiKey,
@@ -24507,15 +24886,6 @@ async function createPreContextServices(input) {
24507
24886
  logger.warn(`models.dev refresh failed (${toErrorMessage12(err)}); using cached catalog`);
24508
24887
  }
24509
24888
  }
24510
- try {
24511
- await installCatalogModelOutputLimits({
24512
- registry: modelsRegistry,
24513
- getConfig: () => config,
24514
- log: (message) => logger.debug(message)
24515
- });
24516
- } catch (err) {
24517
- logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
24518
- }
24519
24889
  try {
24520
24890
  await discoverAndMergeWebuiProviders({
24521
24891
  config,
@@ -24526,6 +24896,15 @@ async function createPreContextServices(input) {
24526
24896
  } catch (err) {
24527
24897
  logger.debug(`provider auto-discovery skipped: ${toErrorMessage12(err)}`);
24528
24898
  }
24899
+ try {
24900
+ await installCatalogModelOutputLimits({
24901
+ registry: modelsRegistry,
24902
+ getConfig: () => config,
24903
+ log: (message) => logger.debug(message)
24904
+ });
24905
+ } catch (err) {
24906
+ logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
24907
+ }
24529
24908
  const events = opts.services?.events ?? new EventBus();
24530
24909
  events.setLogger(logger);
24531
24910
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry, events });
@@ -24912,7 +25291,16 @@ function buildRoutes(state, deps2, cb) {
24912
25291
  refineModel: (ws, msg) => modelOperations.refineModel(
24913
25292
  ws,
24914
25293
  msg.payload
24915
- )
25294
+ ),
25295
+ fallbackChoice: async (ws, msg) => {
25296
+ const result = emitFallbackChoice(deps2.events, msg);
25297
+ if (!result.ok) {
25298
+ send(ws, {
25299
+ type: "error",
25300
+ payload: { phase: "invalid_request", message: result.message }
25301
+ });
25302
+ }
25303
+ }
24916
25304
  };
24917
25305
  const sessionRoutes = createSessionHandlers({
24918
25306
  config: state.getConfig(),
@@ -25090,8 +25478,10 @@ function buildRoutes(state, deps2, cb) {
25090
25478
  });
25091
25479
  const mcpRoutes = {
25092
25480
  list: (ws, msg) => handleMcpList(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25093
- add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25094
- update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25481
+ // add/update are the spawn-capable pair they take a `command`/`args`
25482
+ // from the wire and start it. They go past the trust boundary (M1).
25483
+ add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
25484
+ update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
25095
25485
  remove: (ws, msg) => handleMcpRemove(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25096
25486
  enable: (ws, msg) => handleMcpEnable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25097
25487
  disable: (ws, msg) => handleMcpDisable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
@@ -25263,7 +25653,10 @@ function createWsServers(httpServer, ports, accessToken) {
25263
25653
  expectedToken: wsToken,
25264
25654
  requireToken: ports.requireToken,
25265
25655
  allowedHostnames: publicHostnames,
25266
- allowBrowserUrlToken: Boolean(ports.publicWsUrl)
25656
+ allowBrowserUrlToken: Boolean(ports.publicWsUrl),
25657
+ // WS-003 opt-out for the Vite dev loop only (app and WS server cannot
25658
+ // share a port). Off unless explicitly requested — see ws-auth.ts.
25659
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
25267
25660
  });
25268
25661
  const WS_MAX_PAYLOAD = 20 * 1024 * 1024;
25269
25662
  const wssPrimary = new WebSocketServer({