cortico 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +7 -14
  2. package/package.json +1 -3
  3. package/src/boot.ts +18 -0
  4. package/src/bot.ts +41 -6
  5. package/src/core/README.md +4 -6
  6. package/src/core/blobs.ts +23 -2
  7. package/src/core/config-schema.ts +1 -1
  8. package/src/core/config.ts +2 -16
  9. package/src/core/core.ts +15 -5
  10. package/src/core/loop.ts +55 -27
  11. package/src/core/session.ts +3 -2
  12. package/src/core/timers.ts +8 -6
  13. package/src/core/types.ts +19 -3
  14. package/src/core/util.ts +17 -1
  15. package/src/deploy.ts +4 -1
  16. package/src/extensions/README.md +6 -4
  17. package/src/extensions/dry-mount.ts +23 -2
  18. package/src/extensions/manifest.ts +21 -11
  19. package/src/extensions.ts +147 -23
  20. package/src/launcher.ts +23 -9
  21. package/src/protocol/open-responses/context-log.ts +37 -9
  22. package/src/providers/README.md +32 -8
  23. package/src/providers/base.ts +3 -1
  24. package/src/providers/configuration.ts +2 -1
  25. package/src/providers/console/hub.ts +270 -0
  26. package/src/providers/console/settings.ts +8 -18
  27. package/src/providers/console/types.ts +5 -0
  28. package/src/providers/hub-api.ts +3 -0
  29. package/src/providers/llamacpp/config.ts +8 -6
  30. package/src/providers/llamacpp/console/runtime-panel.ts +5 -1
  31. package/src/providers/llamacpp/console/server.ts +3 -1
  32. package/src/providers/llamacpp/index.ts +3 -1
  33. package/src/providers/llamacpp/native.ts +10 -5
  34. package/src/providers/llamacpp/strings.ts +6 -6
  35. package/src/providers/name.ts +8 -0
  36. package/src/providers/openai-responses-compat/config.ts +13 -0
  37. package/src/providers/openai-responses-compat/console/client.ts +5 -0
  38. package/src/providers/openai-responses-compat/console/reasoning-panel.ts +84 -0
  39. package/src/providers/openai-responses-compat/console/server.ts +127 -0
  40. package/src/providers/openai-responses-compat/index.ts +38 -13
  41. package/src/providers/openai-responses-compat/native.ts +9 -4
  42. package/src/providers/openai-responses-compat/strings.ts +62 -1
  43. package/src/providers/registry.ts +5 -0
  44. package/src/providers/transport/responses-input.ts +59 -10
  45. package/src/web/README.md +11 -6
  46. package/src/web/auth.ts +80 -0
  47. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +5 -4
  48. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +22 -8
  49. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +6 -8
  50. package/src/web/client/console-pages/host.ts +24 -7
  51. package/src/web/client/core/api.ts +5 -1
  52. package/src/web/client/core/router.ts +15 -0
  53. package/src/web/client/features/core/strings.ts +2 -2
  54. package/src/web/client/features/extensions/index.ts +276 -41
  55. package/src/web/client/features/extensions/strings.ts +84 -10
  56. package/src/web/client/features/feature.ts +2 -2
  57. package/src/web/client/features/live/diagnostics.ts +32 -0
  58. package/src/web/client/features/live/index.ts +34 -11
  59. package/src/web/client/features/live/onboarding.ts +4 -1
  60. package/src/web/client/features/live/protocol.ts +1 -0
  61. package/src/web/client/features/live/strings.ts +20 -14
  62. package/src/web/client/features/live/timeline.ts +2 -1
  63. package/src/web/client/features/providers/detail.ts +262 -0
  64. package/src/web/client/features/providers/drafts.ts +23 -0
  65. package/src/web/client/features/providers/index.ts +173 -87
  66. package/src/web/client/features/providers/strings.ts +44 -17
  67. package/src/web/client/features/providers/types.ts +15 -0
  68. package/src/web/client/features/settings/general.ts +13 -0
  69. package/src/web/client/features/settings/index.ts +1 -0
  70. package/src/web/client/features/settings/strings.ts +6 -0
  71. package/src/web/client/features/worlds/index.ts +1 -0
  72. package/src/web/client/main.ts +4 -0
  73. package/src/web/client/shell/index.ts +28 -48
  74. package/src/web/client/shell/strings.ts +0 -22
  75. package/src/web/client/ui/icons.ts +14 -1
  76. package/src/web/client/ui/prompt-input.tsx +17 -5
  77. package/src/web/client/ui/strings.ts +0 -2
  78. package/src/web/console-pages.ts +1 -1
  79. package/src/web/diagnostics.ts +133 -0
  80. package/src/web/public/login.html +67 -0
  81. package/src/web/public/styles.css +143 -26
  82. package/src/web/server.ts +233 -19
  83. package/src/web/shared/client-panel.ts +4 -1
  84. package/src/web/shared/console-protocol.ts +4 -1
  85. package/src/worlds/bilibili/README.md +1 -1
  86. package/src/worlds/bilibili/overlay/server.ts +4 -2
  87. package/src/worlds/minecraft/ADAPT.md +66 -0
  88. package/src/worlds/minecraft/README.md +69 -11
  89. package/src/worlds/minecraft/cell-facts.ts +226 -0
  90. package/src/worlds/minecraft/chests.ts +5 -0
  91. package/src/worlds/minecraft/containers.ts +325 -0
  92. package/src/worlds/minecraft/entity-facts.ts +31 -5
  93. package/src/worlds/minecraft/executor.ts +283 -10348
  94. package/src/worlds/minecraft/inventory.ts +268 -0
  95. package/src/worlds/minecraft/melee.ts +419 -0
  96. package/src/worlds/minecraft/mineflayer-fixes.ts +55 -1
  97. package/src/worlds/minecraft/placed-ledger.ts +152 -0
  98. package/src/worlds/minecraft/placement.ts +1038 -0
  99. package/src/worlds/minecraft/receipt.ts +340 -0
  100. package/src/worlds/minecraft/skill-context.ts +358 -0
  101. package/src/worlds/minecraft/skills-build.ts +1169 -0
  102. package/src/worlds/minecraft/skills-container.ts +1343 -0
  103. package/src/worlds/minecraft/skills-craft.ts +333 -0
  104. package/src/worlds/minecraft/skills-dig.ts +624 -0
  105. package/src/worlds/minecraft/skills-gather.ts +1230 -0
  106. package/src/worlds/minecraft/skills-interact.ts +1559 -0
  107. package/src/worlds/minecraft/tools.ts +331 -0
  108. package/src/worlds/minecraft/travel.ts +763 -0
  109. package/src/worlds/minecraft/until.ts +75 -0
  110. package/src/worlds/qq/normalize.ts +14 -0
  111. package/src/worlds/qq/world.ts +53 -7
  112. package/src/worlds/terminal/world.ts +5 -3
package/src/web/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ProviderHubApi as ProviderHub } from '../providers/hub-api.ts';
1
2
  import type { ContextRecord } from '../protocol/open-responses/context.ts';
2
3
  import type { EnvPromptOrigin } from '../core/prefix.ts';
3
4
  /**
@@ -33,6 +34,7 @@ const SERVER_TEXT = {
33
34
  paused: '已暂停:事件照常落库排队,不投递唤醒',
34
35
  resumed: '已继续:积压事件一次性投递',
35
36
  exitSupervised: '进程即将退出,启动器随即重新拉起',
37
+ exitSupervisedPaused: '进程即将退出,启动器随即重新拉起;回来时事件投递是暂停的,要在运行状态里按继续',
36
38
  exitUnsupervised: '进程即将退出;没有检测到启动器循环,需要手动重新启动',
37
39
  shutdownSkipped: (n: number, labels: string[]) => `本地关机完成,但有 ${n} 步没走完:${labels.join('、')}`,
38
40
  shutdownComplete: (n: number) => `本地关机完成(${n} 步全部走完)`,
@@ -45,6 +47,7 @@ const SERVER_TEXT = {
45
47
  paused: 'Paused: events are still stored and queued, no wake is delivered',
46
48
  resumed: 'Resumed: the backlog is delivered in one batch',
47
49
  exitSupervised: 'The process is about to exit; the launcher will start it again',
50
+ exitSupervisedPaused: 'The process is about to exit; the launcher will start it again with event delivery paused, so resume it in the run status',
48
51
  exitUnsupervised: 'The process is about to exit; no launcher loop was detected, so it must be started again by hand',
49
52
  shutdownSkipped: (n: number, labels: string[]) => `Local shutdown finished, but ${n} step(s) did not complete: ${labels.join(', ')}`,
50
53
  shutdownComplete: (n: number) => `Local shutdown finished (all ${n} steps completed)`,
@@ -55,12 +58,14 @@ const SERVER_TEXT = {
55
58
  },
56
59
  };
57
60
  import { logPredicate, readRunsIndex, readTailRecordsWhere } from './files.ts';
61
+ import { AUTH_KEY_FILE, ConsoleAuth, SESSION_COOKIE, SESSION_COOKIE_MAX_AGE_SEC, cookieValue } from './auth.ts';
62
+ import { buildDiagnostics, DIAGNOSTICS_TAIL } from './diagnostics.ts';
58
63
  import { ConsoleAssets, ConsolePageRegistry, type ConsolePageSource } from './console-pages.ts';
59
64
  import { THEME_FILE, readDeploymentTheme, writeDeploymentTheme } from './theme-store.ts';
60
65
  import { THEME_SCRIPT_ID, type InjectedTheme, type StoredTheme } from './shared/theme.ts';
61
66
  import { EXTENSION_ASSET_PREFIX, extensionAssetSegment, type ExtensionConsoleAsset } from '../extensions/manifest.ts';
62
67
  import {
63
- CONSOLE_LAMPS_ROUTE, CONSOLE_LANGUAGE_HEADER, CONSOLE_LANGUAGE_QUERY, CONSOLE_PROTOCOL_VERSION,
68
+ CONSOLE_AUTH_HEADER, CONSOLE_LAMPS_ROUTE, CONSOLE_LANGUAGE_HEADER, CONSOLE_LANGUAGE_QUERY, CONSOLE_PROTOCOL_VERSION,
64
69
  PROVIDERS_LAMP_ID,
65
70
  isBinaryResult, isFileResult,
66
71
  type ConsoleFileResult, type ConsoleLamp, type ConsoleStream,
@@ -192,28 +197,76 @@ export interface ExtensionInfo {
192
197
  state: 'loaded' | 'failed' | 'pending-restart' | 'removed' | 'idle';
193
198
  }
194
199
 
200
+ /**
201
+ * 搜索结果里的一条。字段全部来自 registry 的搜索端点,够列表排序与筛选;
202
+ * 契约版本、体积、版本史这些要另取包文档,见 `ExtensionPackageDetail`。
203
+ */
195
204
  export interface ExtensionSearchHit {
196
205
  name: string;
197
206
  version: string;
198
207
  description: string;
208
+ /** 这个版本的发布时间 */
199
209
  date?: string;
200
210
  publisher?: string;
211
+ license?: string;
212
+ keywords?: string[];
201
213
  /** 月下载量 */
202
214
  downloads: number;
215
+ /** npm 上依赖这个包的包数 */
216
+ dependents: number;
203
217
  links: { npm?: string; repository?: string; homepage?: string };
204
218
  installed: boolean;
205
219
  /** 按哪一类关键字搜到的(`cortico-world` → world,`cortico-provider` → provider,`cortico-bot` → bot) */
206
220
  kind?: 'world' | 'provider' | 'bot';
207
221
  }
208
222
 
223
+ /** 一个 npm 包的详情。操作员点开某条搜索结果时才取。 */
224
+ export interface ExtensionPackageDetail {
225
+ name: string;
226
+ /** dist-tag `latest` 指的版本 */
227
+ version: string;
228
+ description?: string;
229
+ license?: string;
230
+ keywords?: string[];
231
+ /** latest 版本的发布时间 */
232
+ published?: string;
233
+ /** 这个包第一次发布的时间 */
234
+ created?: string;
235
+ versionCount: number;
236
+ /** 最近几个版本,新的在前 */
237
+ history: Array<{ version: string; date: string }>;
238
+ /** npm 上标了 deprecated 时是那句话 */
239
+ deprecated?: string;
240
+ /** package.json 的 cortico 块解析通过时给出 */
241
+ manifest?: { kind: 'world' | 'provider' | 'bot'; api: number; consoleClient?: string; consoleStyle?: string };
242
+ /** 解析不通过的理由,与本机装载时用的是同一套判据 */
243
+ problems?: string[];
244
+ warnings: string[];
245
+ /** 本进程对这个包所属那一类的契约版本;manifest 解析不通过时缺席 */
246
+ frameworkApi?: number;
247
+ /** `engines.node` */
248
+ engines?: string;
249
+ unpackedSize?: number;
250
+ fileCount?: number;
251
+ dependencies: string[];
252
+ maintainers: string[];
253
+ publisher?: string;
254
+ links: { npm: string; repository?: string; homepage?: string; bugs?: string };
255
+ installed: boolean;
256
+ /** 已安装时 extensions/package.json 里写的那个版本范围 */
257
+ installedSpec?: string;
258
+ }
259
+
209
260
  /** 装 npm 上的包(可带版本或 dist-tag),或本机一个含 package.json 的目录。 */
210
261
  export type ExtensionInstallTarget = { name: string; version?: string } | { path: string };
211
262
 
212
263
  /** 扩展面:清单、搜索、装卸。装卸只改磁盘,加载要重启进程。 */
213
264
  export interface WebAppExtensionDeps {
214
265
  list(): { dir: string; extensions: ExtensionInfo[] };
215
- /** 不给 kind = world(`cortico-world`)。 */
216
- search(query: string, kind?: 'world' | 'provider' | 'bot'): Promise<ExtensionSearchHit[]>;
266
+ /** 该类关键字下 npm 上的全部包;不给 kind = world(`cortico-world`)。 */
267
+ search(kind?: 'world' | 'provider' | 'bot'): Promise<ExtensionSearchHit[]>;
268
+ /** 单个包的详情(另取一次包文档)。 */
269
+ packageInfo(name: string): Promise<ExtensionPackageDetail>;
217
270
  /**
218
271
  * 已加载扩展的浏览器端产物。服务端据此把页 id 映到 `/assets/extensions/<包>/<版本>/<文件>`
219
272
  * 并只发这几个文件;缺席 = 没有扩展带面板。
@@ -415,6 +468,12 @@ export interface ConsoleSurface {
415
468
  * 要放到反向代理后面或有意让别的机器访问,才显式换成 `0.0.0.0`。
416
469
  */
417
470
  host?: string;
471
+ /** 回环名与监听地址之外还接受的 Host 名;以它为 Origin 的写请求与 WebSocket 同样放行。 */
472
+ allowedHosts?: readonly string[];
473
+ /** 访问密码;缺省或空串时不要求登录。 */
474
+ password?: string;
475
+ /** 登录失败的回应延迟(毫秒);缺省见 FAILED_LOGIN_DELAY_MS,测试里调到 0。 */
476
+ failedLoginDelayMs?: number;
418
477
  /** 主循环状态快照(token/截断/梦状态…有什么给什么) */
419
478
  getStatus(): Record<string, unknown>;
420
479
  /** 调试通道(可选;不挂载时 /ws/debug 拒绝连接) */
@@ -447,7 +506,10 @@ export interface ConsoleSurface {
447
506
  dismiss(): void;
448
507
  };
449
508
 
450
- /** 运行控制缺席时 /api/run/* 返回 503;暂停期间事件仍落库排队。关机顺序与进程退出由装配层决定。 */
509
+ /**
510
+ * 可选的运行控制,未挂载时 /api/run/* 返回 503;暂停期间事件仍落库排队。
511
+ * shutdown 按装配层顺序停止投递、IO、托管 LLM server 并落盘,各步有时间预算并返回结果;未提供时不显示关机键,进程退出由装配层决定。
512
+ */
451
513
  run?: {
452
514
  pause(): void;
453
515
  resume(): void;
@@ -460,6 +522,8 @@ export interface ConsoleSurface {
460
522
  */
461
523
  restart?(language: Language): Promise<WebAppShutdownReport>;
462
524
  supervised?: boolean;
525
+ /** 重启后是否以暂停态回来;重启回执据此说清楚。 */
526
+ startsPaused?: boolean;
463
527
  };
464
528
  /** 扩展装卸(可选;不挂载时 /api/extensions* 503)。 */
465
529
  extensions?: WebAppExtensionDeps;
@@ -469,7 +533,10 @@ export interface ConsoleSurface {
469
533
  * 不挂载时 /api/console/manifest 返回一份只有 framework 能力的空 manifest。
470
534
  */
471
535
  consolePageSources?: () => ConsolePageSource[];
472
- /** 所有 provider 模块的本地可用性汇总;缺席时省略该状态。 */
536
+ /**
537
+ * 有没有一个可用的端点。缺省不挂:「模型提供商」那一行不点灯。
538
+ */
539
+ providers?: ProviderHub;
473
540
  providersLamp?: (language: Language) => ConsoleLamp;
474
541
  /**
475
542
  * 浏览器端构建产物目录(含 asset-manifest.json)。缺省取仓库的 `dist/web`。
@@ -538,7 +605,7 @@ const hasPort = (h: string): boolean => /:\d+$/.test(h);
538
605
  const barePort = (h: string): string => h.replace(/:\d+$/, '');
539
606
 
540
607
  /** 按 Host 校验 Origin;缺失 Origin 时放行,null、非法或主机不匹配时拒绝。 */
541
- function isForeignOrigin(origin: unknown, hostHeader: unknown): boolean {
608
+ function isForeignOrigin(origin: unknown, hostHeader: unknown, allowedHosts: readonly string[] = []): boolean {
542
609
  if (typeof origin !== 'string' || origin === '') return false;
543
610
  if (origin === 'null') return true; // 沙箱 iframe / file:// 一类,不是本控制台
544
611
  let originHost: string;
@@ -547,6 +614,8 @@ function isForeignOrigin(origin: unknown, hostHeader: unknown): boolean {
547
614
  } catch {
548
615
  return true;
549
616
  }
617
+ // 反向代理改写 Host 时 Origin 与 Host 不同名;操作员点名的对外名字照样算本站。
618
+ if (namesHost(allowedHosts, originHost)) return false;
550
619
  const host = typeof hostHeader === 'string' ? hostHeader : '';
551
620
  if (!originHost || !host) return true;
552
621
  if (originHost === host) return false;
@@ -555,17 +624,35 @@ function isForeignOrigin(origin: unknown, hostHeader: unknown): boolean {
555
624
  return true;
556
625
  }
557
626
 
627
+ /** 表里的名字可带端口也可不带;不带端口的匹配任意端口。比较不分大小写。 */
628
+ function namesHost(names: readonly string[], host: string): boolean {
629
+ const full = host.toLowerCase();
630
+ const bare = barePort(full);
631
+ return names.some((name) => {
632
+ const n = name.trim().toLowerCase();
633
+ return n !== '' && (n === full || n === bare);
634
+ });
635
+ }
636
+
637
+ /** 对端地址是不是本机回环(含 IPv4 映射的 IPv6 写法)。 */
638
+ function isLoopbackPeer(address: string | undefined): boolean {
639
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
640
+ }
641
+
558
642
  const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
559
643
  const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
560
644
 
561
645
  /**
562
- * Host 头必须是回环名或显式绑定的地址;绑定通配地址时不校验 Host
646
+ * Host 头必须是回环名或显式绑定的地址。DNS rebinding 让外站页面的 Origin 与 Host 同名,
647
+ * 同源闸门因此失效;这一关把请求钉在浏览器真正访问的名字上。绑到通配地址即操作员
648
+ * 已选择对外露面,不校验。
563
649
  */
564
- function isAllowedHost(hostHeader: unknown, listenHost: string): boolean {
650
+ function isAllowedHost(hostHeader: unknown, listenHost: string, allowedHosts: readonly string[] = []): boolean {
565
651
  if (WILDCARD_HOSTS.has(listenHost)) return true;
566
652
  if (typeof hostHeader !== 'string' || hostHeader === '') return false;
567
653
  const name = barePort(hostHeader).toLowerCase();
568
- return LOOPBACK_HOSTS.has(name) || name === listenHost.toLowerCase() || name === `[${listenHost.toLowerCase()}]`;
654
+ return LOOPBACK_HOSTS.has(name) || name === listenHost.toLowerCase() || name === `[${listenHost.toLowerCase()}]`
655
+ || namesHost(allowedHosts, hostHeader);
569
656
  }
570
657
 
571
658
  /**
@@ -708,6 +795,8 @@ export class WebApp {
708
795
  private readonly deps: WebAppDeps;
709
796
  private readonly app: express.Express;
710
797
  private readonly listenHost: string;
798
+ private readonly allowedHosts: readonly string[];
799
+ private readonly auth: ConsoleAuth;
711
800
  /** 控制台页聚合。没挂 consolePageSources 时它也在,只是永远收到空清单。 */
712
801
  private readonly consolePages: ConsolePageRegistry;
713
802
  private readonly assets: ConsoleAssets;
@@ -726,6 +815,8 @@ export class WebApp {
726
815
  constructor(deps: WebAppDeps) {
727
816
  this.deps = deps;
728
817
  this.listenHost = deps.host ?? '127.0.0.1';
818
+ this.allowedHosts = deps.allowedHosts ?? [];
819
+ this.auth = new ConsoleAuth(deps.password ?? '', join(deps.dataDir, AUTH_KEY_FILE), deps.failedLoginDelayMs);
729
820
  this.language = deps.language ?? systemLanguage();
730
821
  this.webDistDir = deps.webDistDir ?? fileURLToPath(new URL('../../dist/web', import.meta.url));
731
822
  this.extensionAssets = deps.extensions?.consoleAssets?.() ?? [];
@@ -809,6 +900,7 @@ export class WebApp {
809
900
  supervised: this.deps.run?.supervised === true,
810
901
  extensions: !!this.deps.extensions,
811
902
  avatar: !!this.deps.botDir,
903
+ auth: this.auth.enabled,
812
904
  };
813
905
  }
814
906
 
@@ -932,6 +1024,29 @@ export class WebApp {
932
1024
  }
933
1025
  }
934
1026
 
1027
+ private authorized(req: { headers: Record<string, unknown> }): boolean {
1028
+ return !this.auth.enabled || this.auth.verify(cookieValue(req.headers.cookie, SESSION_COOKIE));
1029
+ }
1030
+
1031
+ /** 直连 HTTPS,或反向代理报告 X-Forwarded-Proto: https。 */
1032
+ private overHttps(req: Request): boolean {
1033
+ return req.secure || req.headers['x-forwarded-proto'] === 'https';
1034
+ }
1035
+
1036
+ /** HTTPS 下加 Secure;HttpOnly 使页面脚本读不到令牌。 */
1037
+ private sessionCookie(value: string, maxAgeSec: number, req: Request): string {
1038
+ const secure = this.overHttps(req);
1039
+ return `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSec}${secure ? '; Secure' : ''}`;
1040
+ }
1041
+
1042
+ private serveLogin(res: Response): void {
1043
+ const file = fileURLToPath(new URL('./public/login.html', import.meta.url));
1044
+ let html = readFileSync(file, 'utf8');
1045
+ if (this.language === 'en') html = html.replace('<html lang="zh-CN">', '<html lang="en">');
1046
+ res.setHeader('Cache-Control', 'no-store');
1047
+ res.type('html').send(html);
1048
+ }
1049
+
935
1050
  private safeSessionList(): SessionStats[] {
936
1051
  try {
937
1052
  return this.deps.sessions?.list() ?? [];
@@ -1086,7 +1201,7 @@ export class WebApp {
1086
1201
  socket.destroy();
1087
1202
  return;
1088
1203
  }
1089
- if (!isAllowedHost(req.headers.host, this.listenHost)) {
1204
+ if (!isAllowedHost(req.headers.host, this.listenHost, this.allowedHosts)) {
1090
1205
  this.deps.log.warn('拒绝 Host 不在白名单的 WebSocket 连接', {
1091
1206
  path: pathname, host: String(req.headers.host),
1092
1207
  });
@@ -1094,13 +1209,18 @@ export class WebApp {
1094
1209
  return;
1095
1210
  }
1096
1211
  // 跨站页面开的 WS 不受同源策略限制,只能在 upgrade 这一关自己拦(见 isForeignOrigin)
1097
- if (isForeignOrigin(req.headers.origin, req.headers.host)) {
1212
+ if (isForeignOrigin(req.headers.origin, req.headers.host, this.allowedHosts)) {
1098
1213
  this.deps.log.warn('拒绝跨站WebSocket连接', {
1099
1214
  path: pathname, origin: String(req.headers.origin),
1100
1215
  });
1101
1216
  socket.destroy();
1102
1217
  return;
1103
1218
  }
1219
+ if (!this.authorized(req)) {
1220
+ this.deps.log.warn('拒绝未登录的 WebSocket 连接', { path: pathname });
1221
+ socket.destroy();
1222
+ return;
1223
+ }
1104
1224
  wss.handleUpgrade(req, socket, head, (ws) => {
1105
1225
  if (stream) {
1106
1226
  this.handleConsolePageStream(ws, stream.pageId, stream.panelId, this.languageOf(req));
@@ -1136,7 +1256,8 @@ export class WebApp {
1136
1256
  // 固定端口被占时顺延;0 交给 OS 分配且只尝试一次。
1137
1257
  //
1138
1258
  // 固定端口最多尝试 5 个候选端口。
1139
- const maxAttempts = port === 0 ? 1 : 5;
1259
+ // 候选不越过 65535:listen(65536) 抛的是非法端口,会盖住真正的监听错误。
1260
+ const maxAttempts = port === 0 ? 1 : Math.min(5, 65536 - port);
1140
1261
  let server: Server | null = null;
1141
1262
  let lastErr: unknown;
1142
1263
  for (let i = 0; i < maxAttempts; i++) {
@@ -1146,14 +1267,15 @@ export class WebApp {
1146
1267
  if (i > 0) {
1147
1268
  // 端口顺延记录 warn,便于发现预期端口被其他实例占用。
1148
1269
  this.deps.log.warn(
1149
- `端口 ${port} 被占用,改用 ${candidate}。`,
1270
+ `端口 ${port} 被占用或不可监听,改用 ${candidate}。`,
1150
1271
  );
1151
1272
  }
1152
1273
  break;
1153
1274
  } catch (err) {
1154
1275
  lastErr = err;
1155
1276
  const code = (err as NodeJS.ErrnoException)?.code;
1156
- if (code !== 'EADDRINUSE' || port === 0) throw err;
1277
+ // EACCES EADDRINUSE 一样只说明这个候选端口此刻不能监听,成因不论(Windows 的排除端口段、权限、安全策略);试下一个,候选用尽时抛最后一个错误。
1278
+ if ((code !== 'EADDRINUSE' && code !== 'EACCES') || port === 0) throw err;
1157
1279
  }
1158
1280
  }
1159
1281
  if (!server) {
@@ -1166,6 +1288,9 @@ export class WebApp {
1166
1288
  this.wss = wss;
1167
1289
  const actual = (server.address() as AddressInfo).port;
1168
1290
  this.deps.log.info(`web面板已启动 http://${this.listenHost}:${actual}/`);
1291
+ if (!LOOPBACK_HOSTS.has(this.listenHost) && this.listenHost !== '::1' && !this.auth.enabled) {
1292
+ this.deps.log.warn('控制台监听在非回环地址且没有设访问密码,能连到这个端口的人都能操作 bot', { host: this.listenHost });
1293
+ }
1169
1294
  return actual;
1170
1295
  }
1171
1296
 
@@ -1202,7 +1327,7 @@ export class WebApp {
1202
1327
  app.disable('x-powered-by');
1203
1328
 
1204
1329
  app.use((req, res, next) => {
1205
- if (isAllowedHost(req.headers.host, this.listenHost)) {
1330
+ if (isAllowedHost(req.headers.host, this.listenHost, this.allowedHosts)) {
1206
1331
  next();
1207
1332
  return;
1208
1333
  }
@@ -1218,7 +1343,7 @@ export class WebApp {
1218
1343
  next();
1219
1344
  return;
1220
1345
  }
1221
- if (!isForeignOrigin(req.headers.origin, req.headers.host)) {
1346
+ if (!isForeignOrigin(req.headers.origin, req.headers.host, this.allowedHosts)) {
1222
1347
  next();
1223
1348
  return;
1224
1349
  }
@@ -1234,6 +1359,59 @@ export class WebApp {
1234
1359
  if (!res.headersSent) res.status(500).json({ error: String(err) });
1235
1360
  }
1236
1361
  };
1362
+ // 登录、登出与状态三条路由在闸前;其余路由连同各自的 body 解析都在闸后。
1363
+ app.get('/api/auth/status', wrap((req, res) => {
1364
+ res.json({ required: this.auth.enabled, authenticated: this.authorized(req) });
1365
+ }));
1366
+ app.post('/api/auth/login', express.json(), (req: Request, res: Response) => {
1367
+ void (async () => {
1368
+ if (!this.auth.enabled) { res.json({ ok: true }); return; }
1369
+ if (!this.overHttps(req) && !isLoopbackPeer(req.socket.remoteAddress)) {
1370
+ this.deps.log.warn('登录请求从非回环对端经明文连接送来,密码与登录态 Cookie 可被途中截获;公网使用请放在终结 HTTPS 的反向代理后面', { from: req.socket.remoteAddress });
1371
+ }
1372
+ const candidate = (req.body as { password?: unknown } | undefined)?.password;
1373
+ if (typeof candidate !== 'string' || !(await this.auth.login(candidate))) {
1374
+ this.deps.log.warn('控制台登录失败', { from: req.socket.remoteAddress });
1375
+ res.status(401).json({ error: '密码不对' });
1376
+ return;
1377
+ }
1378
+ res.setHeader('Set-Cookie', this.sessionCookie(this.auth.issue(), SESSION_COOKIE_MAX_AGE_SEC, req));
1379
+ this.deps.log.info('控制台登录', { from: req.socket.remoteAddress });
1380
+ res.json({ ok: true });
1381
+ })().catch((err: unknown) => {
1382
+ if (!res.headersSent) res.status(500).json({ error: String(err) });
1383
+ });
1384
+ });
1385
+ app.post('/api/auth/logout', wrap((req, res) => {
1386
+ res.setHeader('Set-Cookie', this.sessionCookie('', 0, req));
1387
+ res.json({ ok: true });
1388
+ }));
1389
+ app.use((req, res, next) => {
1390
+ if (this.authorized(req)) { next(); return; }
1391
+ if (req.method === 'GET' && (req.path === '/' || req.path === '/index.html')) { this.serveLogin(res); return; }
1392
+ res.setHeader(CONSOLE_AUTH_HEADER, 'required');
1393
+ res.status(401).json({ error: '需要登录' });
1394
+ });
1395
+
1396
+ const providerRoute = (handler: (hub: ProviderHub, req: Request) => unknown) => wrap(async (req, res) => {
1397
+ if (!this.deps.providers) { res.status(503).json({ error: 'Provider configuration unavailable.' }); return; }
1398
+ try { res.json((await handler(this.deps.providers, req)) ?? { ok: true });
1399
+ if (req.method === 'POST' && /^\/api\/providers(?:$|\/[^/]+\/(save|delete|activate)$)/.test(req.path)) this.debugBroadcast({ t: 'status', status: this.safeStatus() });
1400
+ }
1401
+ catch (error) { res.status(error instanceof Error && 'status' in error && typeof error.status === 'number' ? error.status : 400).json({ error: error instanceof Error ? error.message : String(error) }); }
1402
+ });
1403
+ app.get('/api/providers', providerRoute((hub, req) => hub.list(this.languageOf(req))));
1404
+ app.get('/api/provider-modules', providerRoute((hub, req) => hub.moduleList(this.languageOf(req))));
1405
+ app.post('/api/provider-modules/config', express.json(), providerRoute((hub, req) => hub.groups(req.body.name || 'draft', req.body.entry, this.languageOf(req))));
1406
+ app.post('/api/provider-modules/preview', express.json(), providerRoute((hub, req) => hub.preview(req.body.name, req.body.entry, req.body.panel, req.body.method, req.body.args ?? [], this.languageOf(req))));
1407
+ app.get('/api/providers/:name', providerRoute((hub, req) => hub.detail(String(req.params.name), this.languageOf(req))));
1408
+ app.post('/api/providers', express.json(), providerRoute((hub, req) => hub.save(null, req.body, this.languageOf(req))));
1409
+ app.post('/api/providers/:name/save', express.json(), providerRoute((hub, req) => hub.save(String(req.params.name), req.body, this.languageOf(req))));
1410
+ app.post('/api/providers/:name/delete', express.json(), providerRoute((hub, req) => hub.delete(String(req.params.name), req.body.expectedRevision)));
1411
+ app.post('/api/providers/:name/activate', providerRoute((hub, req) => hub.activate(String(req.params.name), this.languageOf(req))));
1412
+ app.post('/api/providers/:name/test', providerRoute((hub, req) => hub.action(String(req.params.name), 'test', this.languageOf(req))));
1413
+ app.post('/api/providers/:name/models', providerRoute((hub, req) => hub.action(String(req.params.name), 'models', this.languageOf(req))));
1414
+
1237
1415
  app.get('/api/status', wrap((_req, res) => {
1238
1416
  res.json({ ...this.safeStatus(), uptimeSec: Math.round(process.uptime()) });
1239
1417
  }));
@@ -1319,6 +1497,24 @@ export class WebApp {
1319
1497
  res.json(readTailRecordsWhere(join(this.deps.dataDir, 'runs', runId, 'log.jsonl'), limit, pred));
1320
1498
  }));
1321
1499
 
1500
+ // 诊断包:一次导出把排查一场跑要看的记录收进一个 JSON。缺席的接缝那一段为空。
1501
+ app.get('/api/diagnostics', wrap(async (req, res) => {
1502
+ const dbg = this.deps.debug;
1503
+ res.json(buildDiagnostics({
1504
+ dataDir: this.deps.dataDir,
1505
+ runId: dbg?.runId?.() ?? null,
1506
+ exportedAt: new Date(),
1507
+ status: this.deps.getStatus(),
1508
+ session: dbg ? { messages: dbg.sessionMessages(), head: dbg.sessionHead?.() ?? [] } : null,
1509
+ sessions: this.deps.sessions?.list() ?? [],
1510
+ toolSchemas: dbg?.toolSchemas() ?? [],
1511
+ events: this.deps.store.range({ limit: DIAGNOSTICS_TAIL }),
1512
+ latestCursor: this.deps.store.latestCursor(),
1513
+ worlds: (await this.deps.worlds?.(this.languageOf(req))) ?? [],
1514
+ usage: this.deps.usage?.aggregate({ bucket: 'day' }) ?? null,
1515
+ }));
1516
+ }));
1517
+
1322
1518
  app.get('/api/runs', wrap((_req, res) => {
1323
1519
  res.json({ current: this.deps.debug?.runId?.() ?? null, runs: readRunsIndex(join(this.deps.dataDir, 'runs', 'index.jsonl')) });
1324
1520
  }));
@@ -1387,6 +1583,7 @@ export class WebApp {
1387
1583
  })();
1388
1584
  });
1389
1585
 
1586
+ // 开场引导只出现一次:控制台在操作员开口或按下那颗按钮时销掉标记。
1390
1587
  app.post('/api/onboarding/dismiss', wrap((_req, res) => {
1391
1588
  const src = this.deps.onboarding;
1392
1589
  if (!src) { res.status(503).json({ error: '开场引导标记不可用' }); return; }
@@ -1418,13 +1615,16 @@ export class WebApp {
1418
1615
  );
1419
1616
  });
1420
1617
 
1421
- // 重启 = 落下重启标志 + 规范关机。有没有启动器循环把它拉起来,回执里说明。
1618
+ // 重启 = 落下重启标志 + 规范关机。有没有启动器循环把它拉起来、回来时投递是不是暂停的,回执里说明。
1422
1619
  app.post('/api/run/restart', (req: Request, res: Response) => {
1423
1620
  const supervised = this.deps.run?.supervised === true;
1424
1621
  const language = this.languageOf(req);
1622
+ const text = pick(language, SERVER_TEXT);
1425
1623
  void this.respondPowerAction(
1426
1624
  res, language, this.deps.run?.restart, '重启控制不可用', '收到重启请求(人工操作)',
1427
- supervised ? pick(language, SERVER_TEXT).exitSupervised : pick(language, SERVER_TEXT).exitUnsupervised,
1625
+ !supervised ? text.exitUnsupervised
1626
+ : this.deps.run?.startsPaused ? text.exitSupervisedPaused
1627
+ : text.exitSupervised,
1428
1628
  );
1429
1629
  });
1430
1630
 
@@ -1508,12 +1708,26 @@ export class WebApp {
1508
1708
  return;
1509
1709
  }
1510
1710
  try {
1511
- res.json({ hits: await src.search(strParam(req.query.q) ?? '', kind) });
1711
+ res.json({ hits: await src.search(kind) });
1512
1712
  } catch (err) {
1513
1713
  res.status(502).json({ error: `npm 搜索失败: ${String(err)}` });
1514
1714
  }
1515
1715
  }));
1516
1716
 
1717
+ app.get('/api/extensions/package', wrap(async (req, res) => {
1718
+ const src = this.deps.extensions;
1719
+ if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
1720
+ const name = strParam(req.query.name)?.trim();
1721
+ if (!name) { res.status(400).json({ error: '缺少包名' }); return; }
1722
+ try {
1723
+ res.json(await src.packageInfo(name));
1724
+ } catch (err) {
1725
+ const msg = err instanceof Error ? err.message : String(err);
1726
+ // 包名不合法是请求的问题,其余当成 registry 那一头的问题
1727
+ res.status(msg.includes('包名') ? 400 : 502).json({ error: msg });
1728
+ }
1729
+ }));
1730
+
1517
1731
  app.post('/api/extensions/install', express.json(), wrap(async (req, res) => {
1518
1732
  const src = this.deps.extensions;
1519
1733
  if (!src) { res.status(503).json({ error: '扩展管理不可用' }); return; }
@@ -423,10 +423,13 @@ export interface ConsolePromptImagesOptions {
423
423
  export interface ConsolePromptInputOptions {
424
424
  label?: string;
425
425
  placeholder?: string;
426
+ /** 底栏左侧的灰字。不给就不占字。 */
426
427
  hint?: string;
427
428
  disabled?: boolean;
428
429
  /** 发送键左侧的紧凑工具入口。节点所有权随 prompt input 一起结束。 */
429
430
  tools?: HTMLElement;
431
+ /** 底栏最左的入口,排在 `hint` 之前。节点所有权随 prompt input 一起结束。 */
432
+ leading?: HTMLElement;
430
433
  /** 图片通道。不给 = 不收图,`onSubmit` 的第二参恒为空数组。 */
431
434
  images?: ConsolePromptImagesOptions;
432
435
  /**
@@ -440,7 +443,7 @@ export interface ConsolePromptInput {
440
443
  el: HTMLDivElement;
441
444
  focus(): void;
442
445
  setDisabled(disabled: boolean): void;
443
- /** 设置输入框占位文本;`null` 恢复初值。 */
446
+ /** 盖住输入框里的灰字。`null` 回到构造时给的那一句。 */
444
447
  setPlaceholder(text: string | null): void;
445
448
  }
446
449
 
@@ -14,6 +14,8 @@ export const CONSOLE_PROTOCOL_VERSION = 1;
14
14
  * WebSocket 构造器带不了头)。值是 `zh` / `en`;缺席或不认识时服务端用部署默认语言。
15
15
  */
16
16
  export const CONSOLE_LANGUAGE_HEADER = 'x-cortico-language';
17
+ /** 设了访问密码而请求未登录时,401 响应带这个头;浏览器端据此回到入口页。 */
18
+ export const CONSOLE_AUTH_HEADER = 'x-cortico-auth';
17
19
  export const CONSOLE_LANGUAGE_QUERY = 'language';
18
20
 
19
21
  /** world:World 控制面;llm:LLM 供应模块;persona:bot/Persona 控制面;memory:Persona 的 Memory 页。framework 为保留类别,不经贡献协议提供页面。 */
@@ -401,7 +403,8 @@ export interface ConsoleLampsResponse {
401
403
  }
402
404
 
403
405
  /**
404
- * 框架的 provider 状态灯键,与贡献页 id 使用不同的 kind
406
+ * 「模型提供商」那一行的灯在灯表里的键。贡献方的 page id `<kind>:<id>`,
407
+ * kind 取 world / llm / persona,因此这个键与谁都不撞。
405
408
  */
406
409
  export const PROVIDERS_LAMP_ID = 'framework:providers';
407
410
 
@@ -113,7 +113,7 @@ B 站直播间协议只读接入。观众的弹幕、礼物、醒目留言、上
113
113
 
114
114
  World 在 `127.0.0.1` 启动独立页面:`GET /overlay` 是 OBS browser source,`GET /editor`
115
115
  是编辑器,`GET /stream` 是同源 SSE 数据面,`GET /assets/<id>` 提供上传素材。端口默认 7795,
116
- 被占用时顺延,含配置端口最多尝试 5 个端口,并记录 warn。
116
+ 被占用或不可监听(`listen` 回 `EADDRINUSE` 或 `EACCES`)时顺延,含配置端口最多尝试 5 个端口,并记录 warn。
117
117
  编辑接口只接受浏览器携带的同源 `Origin` 与 JSON 请求,并限制请求体大小;
118
118
  服务不发送 wildcard CORS。设计和公告分别使用修订号做冲突检测,素材、设计与公告写入共用串行队列,
119
119
  避免保存过程中删除素材或后完成的旧请求覆盖新状态。
@@ -98,14 +98,16 @@ export class BilibiliOverlayServer {
98
98
  this.boundPort = (server.address() as AddressInfo).port;
99
99
  if (index > 0) {
100
100
  log.warn(
101
- `B站 Overlay 端口 ${preferred} 被占用,改用 ${this.boundPort}`,
101
+ `B站 Overlay 端口 ${preferred} 被占用或不可监听,改用 ${this.boundPort}`,
102
102
  );
103
103
  }
104
104
  break;
105
105
  } catch (error) {
106
106
  lastError = error;
107
107
  server.close();
108
- if ((error as NodeJS.ErrnoException)?.code !== 'EADDRINUSE' || preferred === 0) throw error;
108
+ const code = (error as NodeJS.ErrnoException)?.code;
109
+ // EACCES 与 EADDRINUSE 一样只说明这个候选端口此刻不能监听,成因不论(Windows 的排除端口段、权限、安全策略);试下一个,候选用尽时抛最后一个错误。
110
+ if ((code !== 'EADDRINUSE' && code !== 'EACCES') || preferred === 0) throw error;
109
111
  }
110
112
  }
111
113
  if (!this.http) throw lastError instanceof Error ? lastError : new Error('B站 Overlay 没有可用端口');
@@ -0,0 +1,66 @@
1
+ <!-- Owner: src/worlds/minecraft/mineflayer-fixes.ts -->
2
+
3
+ # 版本适配
4
+
5
+ Minecraft 服务端换一个版本,客户端这边要跟着改的事实记在这里:版本边界、认得出它的
6
+ 判据、改在哪、当时怎么量出来的。
7
+
8
+ **接到「她走不动 / 动作被打回 / 某个动作忽然失效」这类报告,先读这一页。** 这类症状里
9
+ 客户端看得见的只有"位置包被打回",光读代码分不出是判据变了还是别的;下面每条都带着
10
+ 当时的实测数字,对得上就直接按那条走。
11
+
12
+ ## 1.21.11:包围盒贴着方块侧面的位置被拒,静默传送回
13
+
14
+ **版本边界** Paper 1.21.8、1.21.9、1.21.10 收下贴面位置,1.21.11 拒。不带任何插件的
15
+ 原版 Paper 即可复现,与服务端分支(Leaves)、反作弊插件无关。
16
+
17
+ **服务端的判据** 把玩家包围盒摆在离方块侧面缝隙 `g` 处发过去:
18
+
19
+ | `g` | 服务端 |
20
+ |---|---|
21
+ | > 0(1e-7 也算) | 收下 |
22
+ | = 0 或 < 0 | 回一个 `position` 包把人传回上一个好位置,**不打 `moved wrongly`** |
23
+
24
+ 竖直方向的判据不同:贴着地面顶面、贴着天花板底面都照收,只有真的插进去才被拒。
25
+
26
+ **客户端为什么总是踩中** `prismarine-physics` 的 `AABB.computeOffsetX/Z` 把移动量削到
27
+ "恰好贴上"。于是走到墙根、沿墙走、上一格台阶时贴着立面起跳,发出去的位置包缝隙恰好
28
+ 为零,每个物理刻都被打回,人钉在原地。
29
+
30
+ **认得出它的样子**
31
+
32
+ - 服务端日志干净:同时段别的玩家有 `moved wrongly`,bot 一条都没有
33
+ - 每秒 1 到 9 个服务端 `position` 包,落点比当前位置略微靠后
34
+ - 单次被放行的净位移 0.02 到 0.03 格
35
+ - `goto-stall-probe` 里 `onGround=false`、三轴速度为零、y 停在起跳弧线中途的值
36
+ (贴墙起跳第一刻被收、第二刻被打回第一刻的位置),`controlKeys` 显示前进键一直按着
37
+
38
+ **改法** `mineflayer-fixes.ts` 的 `installWallGap()`:替换 `prismarine-physics` AABB 原型上的
39
+ `computeOffsetX` 与 `computeOffsetZ`,朝方块走的那一步停在离面 `WALL_GAP`(1e-5)处,已经
40
+ 贴着再往里走时退开同样距离;`computeOffsetY` 不动。bot 的物理与寻路器的判据模拟共用
41
+ 这一个类,改原型两处同时生效。原型上打 `Symbol.for` 标记,装几次都只改一次。
42
+
43
+ **试过没用的**
44
+
45
+ - 补上原版客户端 1.21.2+ 每刻发的 `tick_end`、按键变化时的 `player_input`、位置包里真实的
46
+ `hasHorizontalCollision`:三样一起上,打回次数不变
47
+ - 压掉 mineflayer 收到传送后回的那个位置包、补发 `player_loaded`:与这条无关
48
+
49
+ **验证** 不带插件的 Paper 1.21.11,八种一格台阶几何(斜角单块、正对与斜对高台、贴脸
50
+ 起步、头顶树冠):改前每场被打回 160 到 334 次、两场钉死;改后 16 场全部到达、打回
51
+ 零次,耗时与 1.20.6 同一量级。
52
+
53
+ ## 怎么量一条新的版本差异
54
+
55
+ 服务端改了移动判据时,别读反编译猜,直接问服务端:
56
+
57
+ 1. 取各版本的原版服务端 jar(Paper 的 `fill.papermc.io/v3/projects/paper/versions/<版本>/builds`),
58
+ 一个版本一个进程,端口与存档都隔离。
59
+ 2. 关掉客户端物理(`bot.physicsEnabled = false`),手工写 `bot.entity.position` 把人摆到要
60
+ 测的位置,数 400 毫秒内收到几个服务端 `position` 包。零个 = 服务端认,一个及以上 = 被拒。
61
+ 3. 缝隙按 0.05、1e-3、1e-5、1e-7、0、负值扫一遍;地面与空中各扫一次;水平与竖直分开扫
62
+ ——两个方向的判据可以不一样,1.21.11 就不一样。
63
+ 4. 同一套摆法在相邻版本上各跑一次,边界就出来了。
64
+
65
+ 台架在 `scratch/mc-bench/`(不入库):`rig.ts` 起真服务端配真执行器,`BENCH_JAR` 选服务端
66
+ jar、`BENCH_VERSION` 选协议版本。