@oneciel-ai/ciel-runtime 0.2.10 → 0.2.11

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/ciel_runtime.py CHANGED
@@ -491,7 +491,7 @@ from ciel_runtime_support.runtime_paths import (CHANNEL_COMPACT_REQUEST_PATH, #
491
491
  OLLAMA_MODEL_CATALOG_PATH, PID_PATH, PLAN_ARTIFACTS_DIR,
492
492
  RATE_LIMIT_STATE_PATH, REQUEST_DUMP_PATH, RESPONSE_DUMP_PATH,
493
493
  ROUTER_ACTIVITY_PATH, ROUTER_BASE, ROUTER_CLIENTS_DIR,
494
- ROUTER_EXTERNAL_TOKEN_PATH, ROUTER_HOST, ROUTER_INSTANCE_DIR, ROUTER_PORT, ROUTER_WORKSPACE, SSE_LAST_PATH,
494
+ ROUTER_EXTERNAL_TOKEN_PATH, ROUTER_HOST, ROUTER_INSTANCE_DIR, ROUTER_INSTANCE_ID, ROUTER_PORT, ROUTER_WORKSPACE, SSE_LAST_PATH,
495
495
  SSE_TRACE_PATH, TOOL_CALL_LOG_PATH, USAGE_EVENTS_PATH,
496
496
  WEB_TOOLS_MCP_CONFIG, ZAI_MCP_CONFIG, agy_user_bin_dir,
497
497
  ciel_runtime_user_bin_dir, default_router_port,
@@ -1691,7 +1691,7 @@ def handle_llm_config_post(handler: BaseHTTPRequestHandler, path: str, body: dic
1691
1691
 
1692
1692
  def web_ui_controller() -> WebUiController:
1693
1693
  return WebUiController(
1694
- constants=WebUiConstants(VERSION, ROUTER_ACTIVITY_PATH, CONTEXT_USAGE_PATH, DEFAULT_REQUEST_TIMEOUT_MS),
1694
+ constants=WebUiConstants(VERSION, ROUTER_ACTIVITY_PATH, CONTEXT_USAGE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, ROUTER_WORKSPACE, ROUTER_PORT, ROUTER_INSTANCE_ID),
1695
1695
  projection=WebUiProjectionPorts(current_alias, read_json_file, router_rate_limit_usage, positive_int, timeout_profile_idle_ms, context_limit_for_status),
1696
1696
  display=WebUiDisplayPorts(render_router_home_page, render_web_chat_page, provider_mode_label, api_key_status_line),
1697
1697
  http=WebUiHttpPorts(load_config, get_current_provider, write_text_response),
@@ -2836,7 +2836,7 @@ def _router_server_context() -> RouterServerContext:
2836
2836
  config=load_config())),
2837
2837
  )
2838
2838
  return RouterServerContext(
2839
- health=RouterHealthPresentationPorts(VERSION, SOURCE_FINGERPRINT, os.getpid, getpass.getuser, HOME, ROUTER_INSTANCE_DIR, ROUTER_WORKSPACE, ROUTER_PORT, current_alias),
2839
+ health=RouterHealthPresentationPorts(VERSION, SOURCE_FINGERPRINT, os.getpid, getpass.getuser, HOME, ROUTER_INSTANCE_DIR, ROUTER_WORKSPACE, ROUTER_PORT, ROUTER_INSTANCE_ID, current_alias),
2840
2840
  http_services=http_services,
2841
2841
  server_runtime=server_runtime,
2842
2842
  )
@@ -25,6 +25,7 @@ def build_default_config(provider_defaults: dict[str, Any]) -> dict[str, Any]:
25
25
  "host": "127.0.0.1",
26
26
  "port": 0,
27
27
  "tailscale_https": False,
28
+ "workspace": "",
28
29
  },
29
30
  "speech": {
30
31
  "colab": {
@@ -20,6 +20,7 @@ class RouterHealthPresentationPorts:
20
20
  config_dir: Path
21
21
  workspace: str
22
22
  router_port: int
23
+ instance_id: str
23
24
  current_alias: Callable[[dict[str, Any]], str]
24
25
 
25
26
 
@@ -46,6 +47,7 @@ class RouterServerContext:
46
47
  "config_dir": str(self.health.config_dir),
47
48
  "workspace": self.health.workspace,
48
49
  "router_port": self.health.router_port,
50
+ "instance_id": self.health.instance_id,
49
51
  "provider": provider,
50
52
  "model": self.health.current_alias(cfg),
51
53
  "web_chat": "/ca/web/chat",
@@ -93,7 +93,7 @@ OFFICIAL_CHANNEL_PLUGINS = {
93
93
  }
94
94
 
95
95
  APP_NAME = "Ciel Runtime"
96
- VERSION = "0.2.10"
96
+ VERSION = "0.2.11"
97
97
  CREDITS = "Credits: One Ciel LLC"
98
98
  PRELAUNCH_CANCEL = 10
99
99
  PRELAUNCH_LAUNCH_CODEX = 11
@@ -19,7 +19,11 @@ from ciel_runtime_support.runtime_constants import (
19
19
  ROUTED_COMPAT_PROMPT,
20
20
  )
21
21
  from ciel_runtime_support.runtime_paths import CONFIG_DIR, LOG_PATH, ROUTER_INSTANCE_DIR
22
- from ciel_runtime_support.web_endpoints import web_backend_settings
22
+ from ciel_runtime_support.web_endpoints import (
23
+ current_web_workspace,
24
+ web_backend_owned_by_workspace,
25
+ web_backend_settings,
26
+ )
23
27
 
24
28
 
25
29
  CLAUDE_CODE_GENERATED_GREEDY_OPTIONS = frozenset(
@@ -32,7 +36,11 @@ def web_backend_start_requested(config: dict[str, Any]) -> bool:
32
36
  "1", "true", "yes", "on"
33
37
  }:
34
38
  return True
35
- return web_backend_settings(config).enabled
39
+ settings = web_backend_settings(config)
40
+ return settings.enabled and web_backend_owned_by_workspace(
41
+ settings,
42
+ current_web_workspace(),
43
+ )
36
44
 
37
45
 
38
46
  @dataclass(frozen=True, slots=True)
@@ -109,6 +109,7 @@ ROUTER_WORKSPACE = workspace_identity(
109
109
  os.environ.get("CIEL_RUNTIME_LAUNCH_CWD") or Path.cwd()
110
110
  )
111
111
  _WORKSPACE_DIGEST = hashlib.sha256(ROUTER_WORKSPACE.encode("utf-8", errors="replace")).hexdigest()[:12]
112
+ ROUTER_INSTANCE_ID = f"{ROUTER_PORT}-{_WORKSPACE_DIGEST}"
112
113
  _STATE_DIR_OVERRIDE = str(os.environ.get("CIEL_RUNTIME_STATE_DIR") or "").strip()
113
114
  _TEST_STATE_ISOLATED = str(os.environ.get("CIEL_RUNTIME_TEST_ISOLATED") or "").strip().lower() in {
114
115
  "1",
@@ -121,7 +122,7 @@ ROUTER_INSTANCE_DIR = (
121
122
  if _STATE_DIR_OVERRIDE
122
123
  else CONFIG_DIR
123
124
  if _TEST_STATE_ISOLATED
124
- else CONFIG_DIR / "router-instances" / f"{ROUTER_PORT}-{_WORKSPACE_DIGEST}"
125
+ else CONFIG_DIR / "router-instances" / ROUTER_INSTANCE_ID
125
126
  )
126
127
  LOG_PATH = ROUTER_INSTANCE_DIR / "router.log"
127
128
  LOG_LEVEL_PATH = CONFIG_DIR / "log-level"
@@ -13,6 +13,8 @@ from dataclasses import dataclass
13
13
  from pathlib import Path
14
14
  from typing import Any
15
15
 
16
+ from .workspace_router_selection import workspace_identity
17
+
16
18
 
17
19
  _TRUE = {"1", "true", "yes", "on"}
18
20
  _WILDCARD_HOSTS = {"0.0.0.0", "::", "[::]"}
@@ -49,6 +51,7 @@ class WebBackendSettings:
49
51
  host: str = "127.0.0.1"
50
52
  port: int = 0
51
53
  tailscale_https: bool = False
54
+ workspace: str = ""
52
55
 
53
56
  @property
54
57
  def client_host(self) -> str:
@@ -74,7 +77,34 @@ def web_backend_settings(config: dict[str, Any] | None) -> WebBackendSettings:
74
77
  if enabled_value is not None
75
78
  else bool(tailscale_https or host != "127.0.0.1" or port)
76
79
  )
77
- return WebBackendSettings(enabled, host, port, tailscale_https)
80
+ workspace = workspace_identity(values.get("workspace"))
81
+ return WebBackendSettings(enabled, host, port, tailscale_https, workspace)
82
+
83
+
84
+ def current_web_workspace(
85
+ environ: MutableMapping[str, str] | None = None,
86
+ ) -> str:
87
+ environment = os.environ if environ is None else environ
88
+ return workspace_identity(environment.get("CIEL_RUNTIME_LAUNCH_CWD") or Path.cwd())
89
+
90
+
91
+ def web_backend_owned_by_workspace(
92
+ settings: WebBackendSettings,
93
+ workspace: str | os.PathLike[str] | None = None,
94
+ ) -> bool:
95
+ if not settings.workspace:
96
+ return True
97
+ return settings.workspace == workspace_identity(workspace or current_web_workspace())
98
+
99
+
100
+ def web_backend_owned_by_instance(
101
+ settings: WebBackendSettings,
102
+ router_port: int,
103
+ workspace: str | os.PathLike[str] | None = None,
104
+ ) -> bool:
105
+ if not web_backend_owned_by_workspace(settings, workspace):
106
+ return False
107
+ return not settings.port or settings.port == router_port
78
108
 
79
109
 
80
110
  def load_saved_web_backend(path: os.PathLike[str] | str) -> WebBackendSettings:
@@ -117,7 +147,11 @@ def web_backend_panel_rows(
117
147
 
118
148
 
119
149
  def update_web_backend_config(
120
- config: dict[str, Any], key: str, value: Any, effective_port: int
150
+ config: dict[str, Any],
151
+ key: str,
152
+ value: Any,
153
+ effective_port: int,
154
+ workspace: str | os.PathLike[str] | None = None,
121
155
  ) -> list[str]:
122
156
  current = web_backend_settings(config)
123
157
  enabled = current.enabled
@@ -144,6 +178,7 @@ def update_web_backend_config(
144
178
  "host": host,
145
179
  "port": port or effective_port,
146
180
  "tailscale_https": tailscale_https,
181
+ "workspace": workspace_identity(workspace or current_web_workspace()),
147
182
  }
148
183
  external = host in _WILDCARD_HOSTS or not _is_loopback(host)
149
184
  config["router_debug_external_access"] = external
@@ -152,6 +187,7 @@ def update_web_backend_config(
152
187
  return [
153
188
  f"Web backend: {'on' if enabled else 'off'} · {host}:{port or effective_port}.",
154
189
  f"Tailscale HTTPS: {'on' if tailscale_https else 'off'}.",
190
+ f"Web instance: {config['web_backend']['workspace']}:{port or effective_port}.",
155
191
  f"Runtime is restarting so {scheme} endpoint settings apply now.",
156
192
  ]
157
193
 
@@ -341,6 +377,44 @@ def tailscale_https_url_for_target(
341
377
  return ""
342
378
 
343
379
 
380
+ def tailscale_proxy_target_for_port(
381
+ public_port: int,
382
+ *,
383
+ executable: str | None = None,
384
+ timeout: float = 2.0,
385
+ ) -> str:
386
+ command = executable or shutil.which("tailscale")
387
+ if not command:
388
+ return ""
389
+ try:
390
+ completed = subprocess.run(
391
+ [command, "serve", "status", "--json"],
392
+ capture_output=True,
393
+ text=True,
394
+ timeout=timeout,
395
+ check=False,
396
+ )
397
+ if completed.returncode != 0:
398
+ return ""
399
+ payload = json.loads(completed.stdout)
400
+ except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
401
+ return ""
402
+ web = payload.get("Web") if isinstance(payload.get("Web"), dict) else {}
403
+ suffix = f":{public_port}"
404
+ for authority, config in web.items():
405
+ if public_port == 443:
406
+ matches = str(authority).rsplit(":", 1)[-1] in {"443", str(authority)}
407
+ else:
408
+ matches = str(authority).endswith(suffix)
409
+ if not matches or not isinstance(config, dict):
410
+ continue
411
+ handlers = config.get("Handlers")
412
+ root = handlers.get("/") if isinstance(handlers, dict) else None
413
+ if isinstance(root, dict):
414
+ return str(root.get("Proxy") or "").rstrip("/")
415
+ return ""
416
+
417
+
344
418
  def build_web_endpoint_report(
345
419
  client_host: str,
346
420
  bind_host: str,
@@ -375,13 +449,28 @@ def configure_tailscale_https(router_port: int, https_port: int | None = None) -
375
449
  if not executable:
376
450
  return ["Tailscale HTTPS was requested, but the tailscale CLI was not found."]
377
451
  public_port = https_port or router_port
452
+ target = f"http://127.0.0.1:{router_port}"
453
+ existing_target = tailscale_proxy_target_for_port(
454
+ public_port,
455
+ executable=executable,
456
+ )
457
+ if (
458
+ existing_target
459
+ and existing_target != target
460
+ and public_port != router_port
461
+ ):
462
+ return [
463
+ "Tailscale HTTPS setup refused: "
464
+ f"public port {public_port} already belongs to {existing_target}; "
465
+ f"this runtime is {target}. Use a unique public port."
466
+ ]
378
467
  command = [
379
468
  executable,
380
469
  "serve",
381
470
  f"--https={public_port}",
382
471
  "--bg",
383
472
  "--yes",
384
- f"http://127.0.0.1:{router_port}",
473
+ target,
385
474
  ]
386
475
  try:
387
476
  completed = subprocess.run(
@@ -413,14 +502,24 @@ def configure_requested_web_endpoints(
413
502
  environment = os.environ if environ is None else environ
414
503
  lines: list[str] = []
415
504
  saved = web_backend_settings(config)
416
- requested = (
417
- str(environment.get("CIEL_RUNTIME_TAILSCALE_HTTPS") or "").lower() in _TRUE
418
- or (saved.enabled and saved.tailscale_https)
505
+ explicit = str(environment.get("CIEL_RUNTIME_TAILSCALE_HTTPS") or "").lower() in _TRUE
506
+ owned = web_backend_owned_by_instance(
507
+ saved,
508
+ router_port,
509
+ current_web_workspace(environment),
419
510
  )
511
+ requested = explicit or (saved.enabled and saved.tailscale_https and owned)
420
512
  if requested:
421
513
  configured_port = str(environment.get("CIEL_RUNTIME_TAILSCALE_HTTPS_PORT") or "").strip()
422
514
  https_port = _valid_port(configured_port, "CIEL_RUNTIME_TAILSCALE_HTTPS_PORT") if configured_port else None
423
515
  lines.extend(configure_tailscale_https(router_port, https_port))
516
+ elif saved.enabled and saved.tailscale_https and not owned:
517
+ owner = saved.workspace or "legacy port"
518
+ lines.append(
519
+ "web_tailscale_skipped: settings belong to "
520
+ f"{owner}:{saved.port or 'auto'}, current instance is "
521
+ f"{current_web_workspace(environment)}:{router_port}"
522
+ )
424
523
  lines.extend(build_web_endpoint_report(client_host, bind_host, router_port).status_lines())
425
524
  return lines
426
525
 
@@ -437,11 +536,15 @@ __all__ = [
437
536
  "build_web_endpoint_report",
438
537
  "configure_requested_web_endpoints",
439
538
  "configure_tailscale_https",
539
+ "current_web_workspace",
440
540
  "discover_tailscale_node",
441
541
  "load_saved_web_backend",
442
542
  "tailscale_https_url_for_target",
543
+ "tailscale_proxy_target_for_port",
443
544
  "update_web_backend_config",
444
545
  "web_backend_panel_rows",
546
+ "web_backend_owned_by_instance",
547
+ "web_backend_owned_by_workspace",
445
548
  "web_backend_settings",
446
549
  "web_backend_summary",
447
550
  ]
@@ -12,11 +12,16 @@ def render_web_chat_page(
12
12
  mode: str,
13
13
  api_status: str,
14
14
  timeout_ms: int,
15
+ workspace: str,
16
+ router_port: int,
17
+ instance_id: str,
15
18
  ) -> str:
16
19
  escaped_model = html_lib.escape(model)
17
20
  escaped_provider = html_lib.escape(provider)
18
21
  escaped_mode = html_lib.escape(mode)
19
22
  escaped_api_status = html_lib.escape(api_status)
23
+ escaped_workspace = html_lib.escape(workspace)
24
+ escaped_instance_id = html_lib.escape(instance_id)
20
25
  return f"""<!doctype html>
21
26
  <html lang="en">
22
27
  <head>
@@ -173,6 +178,8 @@ def render_web_chat_page(
173
178
  <div><div class="meta-label">API</div><div class="meta-value">{escaped_api_status}</div></div>
174
179
  <div><div class="meta-label">Timeout</div><div class="meta-value">{timeout_ms:,} ms</div></div>
175
180
  <div><div class="meta-label">Bridge</div><div class="meta-value">active session channel</div></div>
181
+ <div><div class="meta-label">Instance</div><div class="meta-value">{escaped_instance_id}</div></div>
182
+ <div><div class="meta-label">Workspace</div><div class="meta-value">{escaped_workspace}</div></div>
176
183
  </div>
177
184
  <div class="nav">
178
185
  <a href="/">Router Home</a>
@@ -269,6 +276,20 @@ def render_web_chat_page(
269
276
  </dialog>
270
277
  <script>
271
278
  const MODEL = {json.dumps(model)};
279
+ const EXPECTED_INSTANCE_ID = {json.dumps(instance_id)};
280
+ const EXPECTED_WORKSPACE = {json.dumps(workspace)};
281
+ const EXPECTED_ROUTER_PORT = {int(router_port)};
282
+ const ORIGIN_INSTANCE_KEY = 'ciel-runtime-web-chat-origin-instance:' + location.origin;
283
+ const bootstrapParams = new URLSearchParams(location.search);
284
+ const previousOriginInstance = localStorage.getItem(ORIGIN_INSTANCE_KEY) || '';
285
+ const rebindOriginInstance = bootstrapParams.get('rebind') === '1';
286
+ if (!previousOriginInstance || rebindOriginInstance) {{
287
+ localStorage.setItem(ORIGIN_INSTANCE_KEY, EXPECTED_INSTANCE_ID);
288
+ }}
289
+ const boundOriginInstance = rebindOriginInstance ? EXPECTED_INSTANCE_ID : previousOriginInstance;
290
+ let instanceIdentityBlocked = boundOriginInstance && boundOriginInstance !== EXPECTED_INSTANCE_ID
291
+ ? `This browser origin is bound to ${{previousOriginInstance}}, but the page came from ${{EXPECTED_INSTANCE_ID}}.`
292
+ : '';
272
293
  const transcript = document.getElementById('transcript');
273
294
  const composer = document.getElementById('composer');
274
295
  const prompt = document.getElementById('prompt');
@@ -533,6 +554,41 @@ def render_web_chat_page(
533
554
  }}
534
555
  return bubble;
535
556
  }}
557
+ function blockRuntimeIdentity(reason) {{
558
+ const detail = String(reason || 'Runtime identity changed.');
559
+ if (instanceIdentityBlocked === detail && sendButton.disabled) return;
560
+ instanceIdentityBlocked = detail;
561
+ if (eventSource) eventSource.close();
562
+ eventSource = null;
563
+ sendButton.disabled = true;
564
+ attachButton.disabled = true;
565
+ micButton.disabled = true;
566
+ stopActiveSpeech();
567
+ setState('instance mismatch', 'error');
568
+ addBubble('system', 'Web Chat stopped to prevent cross-instance delivery. ' + detail + ' Restore the original proxy target, or intentionally open this URL once with ?rebind=1.');
569
+ }}
570
+ async function verifyRuntimeIdentity(options = {{}}) {{
571
+ if (instanceIdentityBlocked) {{
572
+ if (options.announce !== false) blockRuntimeIdentity(instanceIdentityBlocked);
573
+ return false;
574
+ }}
575
+ try {{
576
+ const response = await fetch('/health', {{headers: {{'accept': 'application/json'}}, cache: 'no-store'}});
577
+ if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
578
+ const health = await response.json();
579
+ const actualInstance = String(health.instance_id || '');
580
+ const actualWorkspace = String(health.workspace || '');
581
+ const actualPort = Number(health.router_port || 0);
582
+ if (actualInstance !== EXPECTED_INSTANCE_ID || actualWorkspace !== EXPECTED_WORKSPACE || actualPort !== EXPECTED_ROUTER_PORT) {{
583
+ blockRuntimeIdentity(`Expected ${{EXPECTED_INSTANCE_ID}} (${{EXPECTED_WORKSPACE}}:${{EXPECTED_ROUTER_PORT}}), received ${{actualInstance || 'unknown'}} (${{actualWorkspace || 'unknown'}}:${{actualPort || 'unknown'}}).`);
584
+ return false;
585
+ }}
586
+ return true;
587
+ }} catch (err) {{
588
+ if (options.announce !== false) setState('identity check failed', 'error');
589
+ return false;
590
+ }}
591
+ }}
536
592
  function structuredWebResponse(message) {{
537
593
  const value = message && message.meta && message.meta.web_response;
538
594
  if (!value || typeof value !== 'object') return null;
@@ -965,6 +1021,7 @@ def render_web_chat_page(
965
1021
  }}
966
1022
  }}
967
1023
  async function startVoiceInput() {{
1024
+ if (!await verifyRuntimeIdentity()) throw new Error('Runtime identity verification failed');
968
1025
  if (!navigator.mediaDevices) throw new Error('This browser does not support microphone recording');
969
1026
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
970
1027
  if (!AudioContextClass) throw new Error('Live voice requires Web Audio support');
@@ -1119,7 +1176,8 @@ def render_web_chat_page(
1119
1176
  historyLoading = false;
1120
1177
  }}
1121
1178
  }}
1122
- function startChannelStream() {{
1179
+ async function startChannelStream() {{
1180
+ if (!await verifyRuntimeIdentity()) return;
1123
1181
  if (eventSource) eventSource.close();
1124
1182
  const url = `/ca/channel/stream?channel=${{encodeURIComponent(channel)}}&recipient=web&after=${{lastId}}&timeout=3600`;
1125
1183
  eventSource = new EventSource(url);
@@ -1137,6 +1195,7 @@ def render_web_chat_page(
1137
1195
  }};
1138
1196
  }}
1139
1197
  async function sendMessage(text, files = [], options = {{}}) {{
1198
+ if (!await verifyRuntimeIdentity()) return;
1140
1199
  setState('queued');
1141
1200
  sendButton.disabled = true;
1142
1201
  attachButton.disabled = true;
@@ -1292,10 +1351,14 @@ def render_web_chat_page(
1292
1351
  transcript.addEventListener('scroll', () => {{
1293
1352
  if (transcript.scrollTop < 48) loadOlderHistory();
1294
1353
  }});
1295
- addBubble('system', `Connected to active session bridge for ${{MODEL}}. Messages are queued on channel ${{channel}} and replies stream back from /ca/channel/stream.`);
1296
- loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
1297
- loadInitialHistory().finally(startChannelStream);
1298
- prompt.focus();
1354
+ addBubble('system', `Connecting to runtime ${{EXPECTED_INSTANCE_ID}} for ${{MODEL}} on ${{EXPECTED_WORKSPACE}}.`);
1355
+ verifyRuntimeIdentity().then(ok => {{
1356
+ if (!ok) return;
1357
+ loadSpeechConfig().catch(() => {{ micButton.disabled = true; }});
1358
+ loadInitialHistory().finally(startChannelStream);
1359
+ prompt.focus();
1360
+ setInterval(() => verifyRuntimeIdentity({{announce: false}}), 5000);
1361
+ }});
1299
1362
  </script>
1300
1363
  </body>
1301
1364
  </html>"""
@@ -17,6 +17,9 @@ class WebUiConstants:
17
17
  activity_path: Path
18
18
  context_usage_path: Path
19
19
  default_timeout_ms: int
20
+ workspace: str = ""
21
+ router_port: int = 0
22
+ instance_id: str = ""
20
23
 
21
24
 
22
25
  @dataclass(frozen=True, slots=True)
@@ -136,6 +139,9 @@ class WebUiController:
136
139
  provider_config,
137
140
  ),
138
141
  timeout_ms=timeout_ms,
142
+ workspace=self.constants.workspace,
143
+ router_port=self.constants.router_port,
144
+ instance_id=self.constants.instance_id,
139
145
  )
140
146
 
141
147
  def handle_get(self, handler: Any, path: str) -> bool:
@@ -32,6 +32,10 @@ The Colab Qwen endpoint remains a batch HTTP API, so the live caption is progres
32
32
 
33
33
  Web Chat requests carry an input mode and a structured response contract. The active agent first sends a short acknowledgement and then a final response containing `spoken`, `overview`, and optional `details` fields. The browser renders the fields separately and sends only `spoken` to TTS, avoiding long Markdown, URLs, code, and tables in synthesized speech. Legacy plain `message` replies remain supported.
34
34
 
35
+ ### Multiple local runtime instances
36
+
37
+ Web backend ownership is scoped to the normalized workspace and router port. A saved Web/Tailscale configuration is not inherited by another workspace, and Ciel refuses to take an explicitly selected Tailscale HTTPS port that already proxies a different local router. `/health` advertises a stable `instance_id` derived from the workspace and port. Web Chat binds each browser origin to that ID, verifies it before sends, voice capture, and SSE reconnects, and stops delivery if a proxy begins returning another runtime. Use `?rebind=1` only when intentionally assigning that browser origin to a different instance.
38
+
35
39
  ## API surface
36
40
 
37
41
  - `GET|POST /ca/speech/config`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneciel-ai/ciel-runtime",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Universal AI coding-agent runtime and model-routing layer for Claude, Codex, AGY, and compatible runtimes.",
5
5
  "license": "MIT",
6
6
  "author": "One Ciel LLC",