code-context-control 2.124.0__py3-none-any.whl → 2.125.0__py3-none-any.whl

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.
cli/c3.py CHANGED
@@ -92,7 +92,7 @@ console = Console() if HAS_RICH else None
92
92
  # Config
93
93
  CONFIG_DIR = ".c3"
94
94
  CONFIG_FILE = ".c3/config.json"
95
- __version__ = "2.124.0"
95
+ __version__ = "2.125.0"
96
96
 
97
97
 
98
98
  def _compress_file_cli(compressor, path, mode="smart", **kw):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.124.0
3
+ Version: 2.125.0
4
4
  Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -380,7 +380,7 @@ C3 also records real per-tool usage to `.c3/tool_telemetry.jsonl`, so estimates
380
380
  ## Security & privacy
381
381
 
382
382
  - **All web servers bind `127.0.0.1` by default** and are guarded against browser-based attacks even on loopback — a Host-header allowlist (defeats DNS rebinding) plus an Origin/Referer check on every request (defeats cross-origin CSRF), with scoped, non-wildcard CORS. There is still **no user authentication**, so do not expose these servers to an untrusted network without auth/TLS in front. Binding to a non-loopback interface is opt-in and warned at startup. *(Hardening added in v2.33.0.)*
383
- - **Mobile gateway (`/api/mobile/*`)** — the Oracle exposes a companion-app surface for the C3 mobile app: merged cross-project feed, project overview, PM board read/write, digest, notification ack, plus the security surface (credential vault, Access Guard rules and masks, tool discipline). Unlike the legacy Oracle GETs it requires the **Discovery Bearer token on every method, GETs included**, validates every project path against the registry, and audits every write to the project's activity log and edit ledger. Pair the app from Oracle Settings → *Mobile app* (QR code carrying URL + token). Remote reachability is the same recipe as the Discovery API: set `bind_host` to the Tailscale/LAN interface IP (avoid `0.0.0.0`) and list that address in `allowed_hosts`, both in `~/.c3/oracle/config.json`. Disable the whole surface with `"mobile_api_enabled": false`, or any subsystem with `mobile_credentials_enabled` / `mobile_access_enabled` / their `_write` twins.
383
+ - **Mobile gateway (`/api/mobile/*`)** — the Oracle exposes a companion-app surface for the C3 mobile app: merged cross-project feed, project overview, PM board read/write, digest, notification ack, plus the security surface (credential vault, Access Guard rules and masks, tool discipline). Unlike the legacy Oracle GETs it requires a **Bearer token on every method, GETs included** — the Discovery token, or (v2.125.0+) a **per-client token**: one credential per paired device, stored hashed in `~/.c3/oracle/clients.json`, revocable one at a time, and carrying a kind (`mobile` or `desk`) that override decisions are audited under. It validates every project path against the registry, and audits every write to the project's activity log and edit ledger. Pair the app from Oracle Settings → *Mobile app* (the QR carries the URL and a freshly minted per-device token; a legacy Discovery-token code is one button away). A same-machine client such as C3 Desk pairs itself with `POST /api/mobile/clients` and the owner-only `~/.c3/oracle/bootstrap.key`. Remote reachability is the same recipe as the Discovery API: set `bind_host` to the Tailscale/LAN interface IP (avoid `0.0.0.0`) and list that address in `allowed_hosts`, both in `~/.c3/oracle/config.json`; with such a bind the Oracle also listens on `127.0.0.1` (`loopback_listener`, default on) so local clients are not refused. Disable the whole surface with `"mobile_api_enabled": false`, or any subsystem with `mobile_credentials_enabled` / `mobile_access_enabled` / their `_write` twins.
384
384
  - **The mobile gateway never returns a credential value.** It has no reveal route by design: entries cross the wire through an allowlist serializer, and a value can only be identified by its length and an on-demand fingerprint. Two further levers ship **off** — `mobile_creds_agent_readable_raise` (raising `agent_readable` from the phone) and `mobile_access_global_scope` (machine-wide access rules) — because each exceeds what a leaked Bearer token could otherwise reach. Deliberately absent from the surface: bulk `.env` import, denial-counter clearing, `set_builtin_disabled`, and the raw-content access preview.
385
385
  - **No telemetry by default.** The OSS package collects nothing. Opt-in Sentry crash reporting requires the `[telemetry]` extra plus both `SENTRY_DSN` and `C3_TELEMETRY_OPT_IN=1`; even then request bodies, local variables, and prompts are stripped.
386
386
  - **LLM memory distillation is local-first.** Cloud distillation (v2.51.0) is off by default and opt-in per project.
@@ -1,7 +1,7 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=e48F6FQloFReUIMtmWsih2t54j9SROzAaHtMkEuttDs,20489
3
3
  cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
4
- cli/c3.py,sha256=TRLSfHOBi-9AxQUAaXYq46tFgaBd4WMKw0WIBnFijHI,416843
4
+ cli/c3.py,sha256=C6fQM1K-pxXJFCuqdqjRVfDyXy5J19BdVlgh2gDa5kk,416843
5
5
  cli/docs.html,sha256=X9M7qC7LWi8pPJNF-4yEM57jhuZ3WeTESECbdDOeNaA,143172
6
6
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
7
7
  cli/hook_access_guard.py,sha256=ba8-un6G9Ap8HCzx7nyhVq3c_G4sOtiVXRy0BzzX8-Y,23349
@@ -123,7 +123,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
123
123
  cli/ui/components/settings.js,sha256=ATbAjBlVIwCNpxq7s191b49a_INQV38iwmySqtJLYwY,79066
124
124
  cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
125
125
  cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
126
- code_context_control-2.124.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
126
+ code_context_control-2.125.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
127
127
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
128
128
  core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
129
129
  core/config.py,sha256=YmkcZwedz_lfDM0ZuI_f4xpe98ixPLcSQFcTCHgRiRg,19206
@@ -132,17 +132,19 @@ core/ide.py,sha256=y9J6dGTD_FpNDSHY38nn_UduP_Ey2iP5XZ9fU_rhY10,6227
132
132
  core/mcp_toml.py,sha256=VNZCjJsr4II_jX3cmjYzDh_OQ7Nk34mLnkg528Tl1GM,7047
133
133
  core/web_security.py,sha256=HwgK5aBATfvRIeO_4gWgzK77gKwGuzVlmiuNGm4gGxI,7884
134
134
  oracle/__init__.py,sha256=-OTD7Jh4mUMA4QgPGthPLWXttgZLpkIPhGQ87ZfHBx0,63
135
- oracle/config.py,sha256=F2m9GfgJbpkmXEecglWF3GYeHtPSVDY6HOXQpiMJ3B8,6324
135
+ oracle/config.py,sha256=cypUJHKAj1AODIuRKefdGI-myHrvEEi4OuNzTcsfMwo,6645
136
+ oracle/listeners.py,sha256=lV59aFuMddq-H8l8328NmDyfI79LaB-rcFyrv5cFmog,8054
136
137
  oracle/mcp_oracle.py,sha256=B5B-qSxi37outsuRHn1Ns6Gj8e7W593rmL34LMzuE8M,8980
137
- oracle/oracle_server.py,sha256=gL0DBXXmkGZPFSV54wQmy8hbeGPU662qBS-0nlHYMs0,49468
138
- oracle/oracle_ui.html,sha256=bfW7t9cgax-HNT5a41duYSUS_w0orSX3uinwPv-Fffs,71766
138
+ oracle/oracle_server.py,sha256=cSv-a7CrqyZCAlKEqJGXm3TPBep6zyULYmY6lOeu5hM,53344
139
+ oracle/oracle_ui.html,sha256=C4NZbQzo1QDx93EZk9kJaeD4IRWUz7OikYZRcaacI4E,72360
139
140
  oracle/services/__init__.py,sha256=Nb4POd1_YIwLVYsGfr-DiK-iKTelkU0fh9m7wjeLQHA,23
140
141
  oracle/services/activity_reporter.py,sha256=ZAxNFyUy7QRP28anQLRbKEwm5SGG97KIQYFQDLjYqlM,11729
141
142
  oracle/services/api_auth.py,sha256=1PW3pG--1DJb_F6qMhP3gBTYHxxPE2bsHmVmIhC81Y8,3566
142
143
  oracle/services/c3_bridge.py,sha256=67v_l44Yjep4Ibh_xwQVLlz9lEifqiDUoXBWIxcfKzQ,18878
143
144
  oracle/services/chat_engine.py,sha256=zvjyFsXGwqRHsvggBwQ6L0hcOovQe0AKyOy2ZdOU0No,55974
144
- oracle/services/chat_poll.py,sha256=GZJdYvB09ifFxSmbwEb52tLZm28wYmYIwQh48e6SmkU,15597
145
+ oracle/services/chat_poll.py,sha256=VrfWjBrJQgAdwNZuHAy7qzN-qAqNByG3Q7WqoItCnfs,15859
145
146
  oracle/services/chat_store.py,sha256=mXH-eWo78dKnVUcPVW_1i0JD2nZYVss5xcyd3HM1KGc,10065
147
+ oracle/services/client_tokens.py,sha256=upPr7ssTb0HK40Wd1E2O-r0JXSgPrZGXghM_thujTOY,8358
146
148
  oracle/services/cross_memory.py,sha256=F8JeYkEFSwQL3iGS9KV1onaMSFj3opr6f3nCxwvznV0,5484
147
149
  oracle/services/discovery_audit.py,sha256=zhKJfLfedTS9LIjNimLERidg0iqAC565svEvp8-IA98,5778
148
150
  oracle/services/federated_graph.py,sha256=QTaP31Z1pa_J2rlI9BOBBtDHta887AAs4v4sCaDSN8g,20660
@@ -151,14 +153,14 @@ oracle/services/insight_engine.py,sha256=lwcdXtjt3CSZLqHVpXx6SG5gDhZYUKwXt4Z8dGy
151
153
  oracle/services/local_session.py,sha256=nkawOjDbotUp5wtZQnC1wPDw3HopNYMkoYqWpP5ToCU,9227
152
154
  oracle/services/memory_reader.py,sha256=rSEmoIXQu2JkolHJIQWpn0piPJpyuf1KZvW_9liQwkU,3727
153
155
  oracle/services/memory_writer.py,sha256=hpvYsxjhl293jEjGcRKfjG5njUaAOXKqbmJaUu1WdUU,6997
154
- oracle/services/mobile_api.py,sha256=9nYE84FRHtgiwv6b9Hs17o3pKYXtPlYCn1Xny_gMLhk,120326
156
+ oracle/services/mobile_api.py,sha256=Mpp9dU-OlGyWgizV0aQRWH0mdjXjBeQseLCWkdeky3Y,128103
155
157
  oracle/services/project_scanner.py,sha256=PkYJtwOLRME1EzwRy3GIkYk6Xw0u1_BzFcaMJ5gJ1_s,6118
156
158
  oracle/services/review_agent.py,sha256=m_fzoHowpiibf0_iz3dF72LWSrwB_FbDd1am-SbeWXg,11273
157
159
  oracle/services/tool_executor.py,sha256=xtAkBWkclh_FwOgzprjGkyRIV8-A7Lc3bz0UFPShrv0,1113
158
160
  oracle/services/tool_registry.py,sha256=mJUZSS99PDH8whkndNxcQXT1uoNxPviE3MotGzj2jtk,26046
159
161
  oracle/ui/activity.js,sha256=zbJjqkY-tTQS4YKcdSEr2Z5zAbwbOIPyTNzldQSPU7I,4830
160
162
  oracle/ui/agents.js,sha256=RNnsGUVg5DlRYiv-Ovzb3W4yyP4z0qTX3o7Y1_BgZpo,4538
161
- oracle/ui/app.js,sha256=fyfFL7YNbl_z1WSUOMXScp4qcavuFVg5cnA7D1i3zYU,795
163
+ oracle/ui/app.js,sha256=Ry_V4qn7WnxmeTFN7xutMQJTgGVaR0vZzidgCxtwp1c,816
162
164
  oracle/ui/busy.js,sha256=ePFGqEheHpn-E971pN_9DbQvvkxD-d2V7Efbw68bpo0,4122
163
165
  oracle/ui/core.js,sha256=sDrD1hWKMliOO6rjmTsaTULXauKzphc5tm9NEcNTcd4,5478
164
166
  oracle/ui/crossgraph.js,sha256=7yBCciuEzZ0LYspWMO0xTe_GuPQ_NccnIeRrqEq5Qxs,9234
@@ -166,7 +168,7 @@ oracle/ui/header.js,sha256=xMymi7x7mvoImlL-1iOfEwmwp7AJkiCp31xNLlGoIgE,4189
166
168
  oracle/ui/insights.js,sha256=zACyfGz9-4y2eFd6OM1IE-L_HyL1wnqo46nJDyRx7-M,2408
167
169
  oracle/ui/projects.js,sha256=ULE6Py1iULVmRGy0mCnEAxKNwHvn-baUYlNDF5xGYM8,13002
168
170
  oracle/ui/qrcode.js,sha256=GK45n4EYK8nekW6cd7GV3yDMWNby1VpisIWimfG_F4A,56694
169
- oracle/ui/settings.js,sha256=rPm18C6QmVJBPICxABou2MPjCITnmo8Xdkgo8klrU8E,9432
171
+ oracle/ui/settings.js,sha256=EzKtxDbTL2SwqfPhRJlb7n2znb-77_a9npuGle1z1h8,12401
170
172
  oracle/ui/suggestions.js,sha256=ItQTlyd6LS_QxvbJmNMYeHY3cl0lSRgBdR53XKkBpP0,2332
171
173
  oracle/ui/theme_tabs.js,sha256=sIFnBLgI_Hp7c-GRZrWN2v4pvRBJUnIhhwGG91qydKI,3347
172
174
  oracle/ui/chat/conversations.js,sha256=FOpXNrIeiJUR9VueBYEuLml-VfJfuHo05ZVkT-x7XRI,5907
@@ -306,8 +308,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
306
308
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
307
309
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
308
310
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
309
- code_context_control-2.124.0.dist-info/METADATA,sha256=aoNZXvOHSJK4ylPra3DMz2K2FMUEkKIKiAYLOUYOWcg,27067
310
- code_context_control-2.124.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
311
- code_context_control-2.124.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
312
- code_context_control-2.124.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
313
- code_context_control-2.124.0.dist-info/RECORD,,
311
+ code_context_control-2.125.0.dist-info/METADATA,sha256=ft9Vv4PsE9GmO_Triq-bEAuOHO-EJT9XzIOMob1UjvQ,27648
312
+ code_context_control-2.125.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
313
+ code_context_control-2.125.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
314
+ code_context_control-2.125.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
315
+ code_context_control-2.125.0.dist-info/RECORD,,
oracle/config.py CHANGED
@@ -11,6 +11,11 @@ DEFAULTS = {
11
11
  "port": 3331,
12
12
  # ── Discovery API (external LLM tool surface, v2.32.0) ──
13
13
  "bind_host": "127.0.0.1", # loopback only by default (was 0.0.0.0)
14
+ # When bind_host is a specific non-loopback address (a Tailscale / LAN
15
+ # IP), ALSO serve the same app on 127.0.0.1 so same-machine clients (the
16
+ # desk tray client, `c3 oracle open`, curl) are not refused. No effect on
17
+ # a loopback or wildcard bind. See oracle/listeners.py.
18
+ "loopback_listener": True,
14
19
  "api_enabled": True, # expose /api/discovery/* REST surface
15
20
  "api_require_auth": True, # require Bearer token on /api/discovery/*
16
21
  "api_max_tier": "action", # cap exposed tools: "read" | "action"
oracle/listeners.py ADDED
@@ -0,0 +1,210 @@
1
+ """Serve the Oracle on its configured bind host AND on loopback.
2
+
3
+ Why
4
+ ---
5
+ ``bind_host`` exists so a phone on the tailnet can reach the Oracle: set it
6
+ to the Tailscale address and the server listens there — and ONLY there. A
7
+ process on the same machine dialling ``http://127.0.0.1:3331`` was refused,
8
+ which broke every same-box client that reasonably assumes loopback: the
9
+ desk tray client, ``c3 oracle open``'s health probe, the single-instance
10
+ check in ``run_oracle``, curl in a terminal.
11
+
12
+ So when the bind host is a specific non-loopback address, the same Flask app
13
+ is served a second time on ``127.0.0.1``, same port. Same app object means
14
+ the same guards: the Host allowlist (``core.web_security``) always admits
15
+ ``localhost`` / ``127.0.0.1``, the Bearer gates do not care which socket a
16
+ request arrived on, and ``local_session.is_local`` already treats loopback
17
+ as same-machine. A wildcard bind (``0.0.0.0`` / ``::``) already answers on
18
+ loopback, and a loopback bind needs no second socket, so neither gets one.
19
+
20
+ Config: ``loopback_listener`` in ``~/.c3/oracle/config.json`` (default
21
+ true). Off = the pre-v2.125.0 behaviour, one socket on ``bind_host``.
22
+
23
+ Shape
24
+ -----
25
+ ``werkzeug.serving.make_server`` builds one threaded WSGI server per host;
26
+ every listener but the first runs ``serve_forever`` in a daemon thread, the
27
+ first blocks the calling thread exactly as ``app.run`` did. ``shutdown()``
28
+ stops every listener, and ``serve_forever`` calls it on the way out
29
+ (Ctrl-C included) so a secondary socket never outlives the primary.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import ipaddress
35
+ import logging
36
+ import threading
37
+
38
+ LOOPBACK_HOST = "127.0.0.1"
39
+
40
+ # Binds that already answer on loopback, so a second socket would only
41
+ # collide with the first.
42
+ _WILDCARD_HOSTS = frozenset({"", "0.0.0.0", "::", "*"})
43
+
44
+ _log = logging.getLogger("oracle.listeners")
45
+
46
+
47
+ def is_loopback_host(host: str | None) -> bool:
48
+ """True for ``localhost`` and any loopback IP literal (v4 or v6)."""
49
+ h = (host or "").strip().lower()
50
+ if h == "localhost":
51
+ return True
52
+ try:
53
+ return ipaddress.ip_address(h.strip("[]")).is_loopback
54
+ except ValueError:
55
+ return False
56
+
57
+
58
+ def listener_hosts(bind_host: str | None, loopback_listener: bool = True) -> list[str]:
59
+ """The hosts to listen on, primary first.
60
+
61
+ >>> listener_hosts("100.77.40.101")
62
+ ['100.77.40.101', '127.0.0.1']
63
+ >>> listener_hosts("127.0.0.1")
64
+ ['127.0.0.1']
65
+ >>> listener_hosts("0.0.0.0")
66
+ ['0.0.0.0']
67
+ >>> listener_hosts("100.77.40.101", loopback_listener=False)
68
+ ['100.77.40.101']
69
+ """
70
+ primary = (bind_host or "").strip() or LOOPBACK_HOST
71
+ hosts = [primary]
72
+ if not loopback_listener:
73
+ return hosts
74
+ if primary.lower() in _WILDCARD_HOSTS or is_loopback_host(primary):
75
+ return hosts
76
+ hosts.append(LOOPBACK_HOST)
77
+ return hosts
78
+
79
+
80
+ class OracleListeners:
81
+ """One Flask app, N sockets. See the module docstring."""
82
+
83
+ def __init__(self, app, hosts: list[str], port: int, *, threaded: bool = True):
84
+ if not hosts:
85
+ raise ValueError("at least one host is required")
86
+ self.app = app
87
+ self.hosts = list(hosts)
88
+ self.port = int(port)
89
+ self.threaded = threaded
90
+ self.servers: list = []
91
+ # Servers whose serve_forever loop has been entered. socketserver's
92
+ # shutdown() waits on an Event that only serve_forever sets, so
93
+ # calling it on a server that was never served blocks forever.
94
+ self._serving: list = []
95
+ self._threads: list[threading.Thread] = []
96
+ self._lock = threading.Lock()
97
+ self._started = False
98
+ self._stopped = False
99
+
100
+ # ── lifecycle ──────────────────────────────────────────
101
+
102
+ def start(self) -> OracleListeners:
103
+ """Bind every socket. The primary is NOT served yet — call
104
+ :meth:`serve_forever` (blocking) or :meth:`serve_in_background`.
105
+
106
+ A secondary bind that fails (something else already holds the port on
107
+ loopback, say) is logged and skipped; the primary failing raises, as
108
+ ``app.run`` would have.
109
+ """
110
+ from werkzeug.serving import make_server
111
+
112
+ with self._lock:
113
+ if self._started:
114
+ return self
115
+ self._started = True
116
+ primary_host = self.hosts[0]
117
+ primary = make_server(primary_host, self.port, self.app, threaded=self.threaded)
118
+ self.servers.append(primary)
119
+ # A port of 0 asks the OS for one; every secondary must then share it.
120
+ if self.port == 0:
121
+ self.port = int(primary.socket.getsockname()[1])
122
+ for host in self.hosts[1:]:
123
+ try:
124
+ srv = make_server(host, self.port, self.app, threaded=self.threaded)
125
+ except (OSError, SystemExit) as exc:
126
+ # werkzeug turns a failed bind into sys.exit(1) after printing
127
+ # the reason; for a SECONDARY socket that is a warning, not
128
+ # the end of the server.
129
+ _log.warning("loopback listener on %s:%s not started: %s",
130
+ host, self.port, exc)
131
+ continue
132
+ self.servers.append(srv)
133
+ self._serving.append(srv)
134
+ t = threading.Thread(target=srv.serve_forever, daemon=True,
135
+ name=f"oracle-listener-{host}")
136
+ t.start()
137
+ self._threads.append(t)
138
+ return self
139
+
140
+ def serve_forever(self) -> None:
141
+ """Serve the primary listener on the calling thread until shutdown.
142
+
143
+ Every other listener is stopped on the way out, whichever way the
144
+ primary exits (``shutdown()`` from another thread, Ctrl-C, an error).
145
+ """
146
+ if not self._started:
147
+ self.start()
148
+ primary = self.servers[0]
149
+ self._serving.append(primary)
150
+ try:
151
+ primary.serve_forever()
152
+ finally:
153
+ self.shutdown()
154
+
155
+ def serve_in_background(self) -> OracleListeners:
156
+ """Serve the primary on a daemon thread too (tests, embedders)."""
157
+ if not self._started:
158
+ self.start()
159
+ primary = self.servers[0]
160
+ self._serving.append(primary)
161
+ t = threading.Thread(target=primary.serve_forever, daemon=True,
162
+ name=f"oracle-listener-{self.hosts[0]}")
163
+ t.start()
164
+ self._threads.insert(0, t)
165
+ return self
166
+
167
+ def shutdown(self) -> None:
168
+ """Stop every listener and close its socket. Idempotent, and safe to
169
+ call from any thread that is not itself inside ``serve_forever``."""
170
+ with self._lock:
171
+ if self._stopped:
172
+ return
173
+ self._stopped = True
174
+ for srv in self.servers:
175
+ if srv in self._serving:
176
+ try:
177
+ srv.shutdown()
178
+ except Exception:
179
+ pass
180
+ try:
181
+ srv.server_close()
182
+ except Exception:
183
+ pass
184
+ for t in self._threads:
185
+ if t is not threading.current_thread():
186
+ t.join(timeout=5)
187
+
188
+ # ── introspection ──────────────────────────────────────
189
+
190
+ @property
191
+ def addresses(self) -> list[tuple[str, int]]:
192
+ """``(host, port)`` per live listener, primary first."""
193
+ out = []
194
+ for srv in self.servers:
195
+ try:
196
+ name = srv.socket.getsockname()
197
+ out.append((str(name[0]), int(name[1])))
198
+ except OSError:
199
+ continue
200
+ return out
201
+
202
+ @property
203
+ def urls(self) -> list[str]:
204
+ out = []
205
+ for host, port in self.addresses:
206
+ shown = "127.0.0.1" if host in ("0.0.0.0", "") else host
207
+ if ":" in shown and not shown.startswith("["):
208
+ shown = f"[{shown}]"
209
+ out.append(f"http://{shown}:{port}")
210
+ return out
oracle/oracle_server.py CHANGED
@@ -19,8 +19,9 @@ if str(_PROJECT_ROOT) not in sys.path:
19
19
  from flask import Flask, Response, jsonify, redirect, request, url_for # noqa: E402
20
20
 
21
21
  from oracle.config import ORACLE_DIR, load_config, save_config # noqa: E402
22
+ from oracle.listeners import OracleListeners, listener_hosts # noqa: E402
22
23
  from oracle.mcp_oracle import mcp_url, start_mcp_thread # noqa: E402
23
- from oracle.services import api_auth, local_session # noqa: E402
24
+ from oracle.services import api_auth, client_tokens, local_session # noqa: E402
24
25
  from oracle.services.activity_reporter import ActivityReporter # noqa: E402
25
26
  from oracle.services.api_auth import extract_bearer # noqa: E402
26
27
  from oracle.services.api_auth import verify as verify_api_key # noqa: E402
@@ -233,6 +234,14 @@ def _local_write_guard():
233
234
  path = request.path or ""
234
235
  if not path.startswith("/api/") or path.startswith("/api/discovery"):
235
236
  return None
237
+ # The mobile gateway authenticates itself: both of its blueprints gate
238
+ # EVERY method (GETs included) on a Bearer — the Discovery token or a
239
+ # per-client token (v2.125.0) — and the one route that takes no Bearer,
240
+ # ``POST /api/mobile/clients``, runs its own local-address + bootstrap-key
241
+ # check. Applying this cookie-or-Discovery gate on top would refuse every
242
+ # per-client token before the blueprint ever saw it.
243
+ if path.startswith("/api/mobile/"):
244
+ return None
236
245
  if request.method in ("GET", "HEAD", "OPTIONS"):
237
246
  return None
238
247
  # The bootstrap mint is how a browser ACQUIRES the cookie, so it cannot
@@ -1086,6 +1095,63 @@ def api_apikey_clear():
1086
1095
  return jsonify({"error": str(e)}), 500
1087
1096
 
1088
1097
 
1098
+ # ── Paired clients (dashboard side of oracle/services/client_tokens.py) ──
1099
+ # Cookie-authenticated like the apikey routes: the mutations sit behind
1100
+ # _local_write_guard, the list is a GET and returns no secret. The QR the
1101
+ # Settings tab renders now carries a per-device token minted here, so
1102
+ # rotating the Discovery token no longer un-pairs every phone.
1103
+
1104
+ @app.route("/api/pair/clients", methods=["GET"])
1105
+ def api_pair_clients():
1106
+ """Every paired client (never a hash or token). Signed-in dashboard only
1107
+ — the list names devices, which is more than an anonymous local process
1108
+ should learn."""
1109
+ if not (verify_api_key(extract_bearer(request.headers.get("Authorization")))
1110
+ or local_session.verify(request.cookies.get(local_session.COOKIE_NAME))):
1111
+ return jsonify({"error": "unauthorized"}), 401
1112
+ try:
1113
+ return jsonify({"clients": client_tokens.list_clients()})
1114
+ except Exception as e:
1115
+ return jsonify({"error": str(e)}), 500
1116
+
1117
+
1118
+ @app.route("/api/pair/mobile", methods=["POST"])
1119
+ def api_pair_mobile():
1120
+ """Mint a per-device ``mobile`` client token for the pairing QR.
1121
+
1122
+ Body ``{label?}``. The token is revealed exactly once, here, for the QR;
1123
+ the store keeps only its hash. ``url`` is what the phone should dial —
1124
+ the dashboard overrides it with the Tailscale/LAN address it entered.
1125
+ """
1126
+ data = request.get_json(silent=True) or {}
1127
+ try:
1128
+ row, token = client_tokens.mint("mobile", str(data.get("label") or "phone"))
1129
+ except ValueError as e:
1130
+ return jsonify({"error": str(e)}), 400
1131
+ except Exception as e:
1132
+ return jsonify({"error": str(e)}), 500
1133
+ return jsonify({
1134
+ "client_id": row["client_id"],
1135
+ "kind": row["kind"],
1136
+ "label": row["label"],
1137
+ "created": row["created"],
1138
+ "token": token,
1139
+ "url": request.host_url.rstrip("/"),
1140
+ }), 201
1141
+
1142
+
1143
+ @app.route("/api/pair/clients/<client_id>", methods=["DELETE"])
1144
+ def api_pair_revoke(client_id):
1145
+ """Un-pair one device. Its token fails on the next request."""
1146
+ try:
1147
+ row = client_tokens.revoke(str(client_id))
1148
+ except Exception as e:
1149
+ return jsonify({"error": str(e)}), 500
1150
+ if row is None:
1151
+ return jsonify({"error": "unknown client"}), 404
1152
+ return jsonify({"client": row, "revoked": True})
1153
+
1154
+
1089
1155
  # ── Error handlers ────────────────────────────────────────
1090
1156
  @app.errorhandler(404)
1091
1157
  def not_found(e):
@@ -1215,7 +1281,27 @@ def run_oracle(port: int = None, open_browser: bool = None):
1215
1281
  signed_in = f"{url}/?{local_session.BOOTSTRAP_PARAM}={local_session.mint_code()}"
1216
1282
  threading.Timer(0.8, lambda: webbrowser.open(signed_in)).start()
1217
1283
 
1218
- app.run(host=cfg.get("bind_host", "127.0.0.1"), port=actual_port, debug=False, use_reloader=False)
1284
+ _serve(bind_host, actual_port,
1285
+ loopback_listener=bool(cfg.get("loopback_listener", True)))
1286
+
1287
+
1288
+ def _serve(bind_host: str, port: int, *, loopback_listener: bool = True) -> None:
1289
+ """Block serving ``app`` on ``bind_host`` — and on loopback too when the
1290
+ bind is a specific non-loopback address (``oracle/listeners.py``).
1291
+
1292
+ Replaces ``app.run``: the same threaded werkzeug server, minus the
1293
+ dev-server banner, plus the second socket. Every listener is stopped on
1294
+ the way out (Ctrl-C, service stop, an error) and at interpreter exit.
1295
+ """
1296
+ hosts = listener_hosts(bind_host, loopback_listener)
1297
+ listeners = OracleListeners(app, hosts, port).start()
1298
+ atexit.register(listeners.shutdown)
1299
+ if len(listeners.servers) > 1:
1300
+ print(f"Loopback listener → {listeners.urls[-1]} (same app, same guards)")
1301
+ elif len(hosts) > 1:
1302
+ print(f"Warning: loopback listener on 127.0.0.1:{port} could not start "
1303
+ f"(port in use?) — same-machine clients must dial {bind_host}.")
1304
+ listeners.serve_forever()
1219
1305
 
1220
1306
 
1221
1307
  if __name__ == "__main__":
oracle/oracle_ui.html CHANGED
@@ -1198,19 +1198,25 @@
1198
1198
  <!-- ── Mobile pairing (companion app) ── -->
1199
1199
  <h2 style="font-size:16px;font-weight:600;margin:28px 0 16px">Mobile app <span style="font-size:11px;color:var(--text2);font-weight:400">— pair the C3 companion app</span></h2>
1200
1200
  <div class="card">
1201
- <p style="font-size:12px;color:var(--text2);margin:0 0 14px;line-height:1.5">Scan a QR code from the C3 mobile companion to pair it with this Oracle. The code carries the server URL and the <b>Discovery API token</b> above — treat it like a password, and note that rotating the token un-pairs every client that uses it.</p>
1201
+ <p style="font-size:12px;color:var(--text2);margin:0 0 14px;line-height:1.5">Scan a QR code from the C3 mobile companion to pair it with this Oracle. The code carries the server URL and a <b>per-device token</b> minted for that phone — treat it like a password. Each device gets its own token, so one can be revoked below without touching the others, and rotating the Discovery API token above no longer un-pairs phones.</p>
1202
1202
  <div class="field">
1203
1203
  <label>Server URL (as reachable from the phone)</label>
1204
1204
  <input id="mpUrl" type="text" placeholder="e.g. http://100.x.y.z:3331 (Tailscale IP)">
1205
1205
  <div style="font-size:11px;color:var(--text2);margin-top:6px">Defaults to this page's origin — replace it with the Tailscale/LAN address the phone can reach. Remote access needs <code>bind_host</code> and <code>allowed_hosts</code> set in <code>~/.c3/oracle/config.json</code>.</div>
1206
1206
  </div>
1207
- <div style="display:flex;gap:8px;margin-top:4px">
1207
+ <div style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap">
1208
1208
  <button class="btn btn-primary" onclick="showMobilePairQr()">Show pairing code</button>
1209
+ <button class="btn btn-ghost" onclick="showDiscoveryPairQr()" title="Legacy: the shared Discovery token, for an app build that predates per-device tokens">Discovery-token code (legacy)</button>
1209
1210
  <button class="btn btn-ghost" id="mpHideBtn" onclick="hideMobilePairQr()" style="display:none">Hide</button>
1210
1211
  </div>
1211
1212
  <div id="mpQrWrap" style="display:none;margin-top:14px">
1212
1213
  <div id="mpQr" style="display:inline-block;background:#fff;padding:14px;border-radius:8px;line-height:0"></div>
1213
- <div style="font-size:11px;color:var(--text2);margin-top:8px">Contains the raw API token — don't screenshot or share it.</div>
1214
+ <div id="mpQrNote" style="font-size:11px;color:var(--text2);margin-top:8px"></div>
1215
+ <div style="font-size:11px;color:var(--text2);margin-top:4px">Contains a raw Bearer token — don't screenshot or share it.</div>
1216
+ </div>
1217
+ <div style="margin-top:16px">
1218
+ <label style="font-size:12px;font-weight:600">Paired devices</label>
1219
+ <div id="mpClients" style="margin-top:6px"></div>
1214
1220
  </div>
1215
1221
  </div>
1216
1222
  </div>
@@ -33,9 +33,8 @@ import threading
33
33
  import time
34
34
  import uuid
35
35
 
36
- from flask import Blueprint, jsonify, request
36
+ from flask import Blueprint, g, jsonify, request
37
37
 
38
- from oracle.services import api_auth
39
38
  from oracle.services.api_auth import extract_bearer
40
39
 
41
40
  #: Capability advertised on ``/api/mobile/info``. Wired into
@@ -115,8 +114,14 @@ def _chat_auth_guard():
115
114
  return None # CORS preflight
116
115
  if not _cfg().get("mobile_api_enabled", True):
117
116
  return jsonify({"error": "mobile API disabled"}), 404
118
- if not api_auth.verify(extract_bearer(request.headers.get("Authorization"))):
117
+ # Same credential set as the gateway proper (Discovery token OR a live
118
+ # per-client token, v2.125.0), resolved by the one shared function so a
119
+ # device paired with a client token can chat too.
120
+ from oracle.services import mobile_api
121
+ who = mobile_api.authenticate(extract_bearer(request.headers.get("Authorization")))
122
+ if who is None:
119
123
  return jsonify({"error": "unauthorized"}), 401
124
+ g.c3_client = who
120
125
  return None
121
126
 
122
127
 
@@ -0,0 +1,239 @@
1
+ """Per-client Bearer tokens for the mobile gateway (``~/.c3/oracle/clients.json``).
2
+
3
+ Why this exists
4
+ ---------------
5
+ Until v2.125.0 the only credential the ``/api/mobile/*`` gateway accepted was
6
+ the single Discovery token (``api_auth``). Every phone carried that one token,
7
+ so rotating it un-paired every device at once, and the server could not tell
8
+ one client from another: every override decision was audited as
9
+ ``decided_by="mobile"`` whether a phone or a desktop tray client tapped it.
10
+
11
+ A *client token* is one credential per paired device. It is minted once (by
12
+ the dashboard's pairing QR, or by ``POST /api/mobile/clients`` with the
13
+ on-disk bootstrap key), returned exactly once, and stored here only as a
14
+ SHA-256 hash. Revoking one device revokes one device.
15
+
16
+ What is stored, and where
17
+ -------------------------
18
+ ``~/.c3/oracle/clients.json`` — a JSON array of rows::
19
+
20
+ {"client_id": "desk-3f9a2c1b0d4e", "kind": "desk", "label": "office pc",
21
+ "token_hash": "<sha256 hex>", "created": "<iso utc>",
22
+ "last_seen": "<iso utc>|null", "revoked_at": "<iso utc>|null"}
23
+
24
+ ``kind`` is one of :data:`KINDS`. The file is written through
25
+ ``services.atomic_json.write_json_atomic`` and chmod'd owner-only, the same
26
+ posture as ``bootstrap.key`` beside it (``local_session.write_bootstrap_key``
27
+ explains why chmod is enough on Windows too). The plaintext token never
28
+ touches disk.
29
+
30
+ Verification is a constant-time compare of the presented token's hash against
31
+ EVERY stored hash — no early exit, so timing does not leak which row matched.
32
+ ``last_seen`` is touched at most once a minute per client so a long-poll
33
+ client does not rewrite the file on every request.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import hashlib
39
+ import json
40
+ import os
41
+ import secrets
42
+ import threading
43
+ import time
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+
47
+ from services.atomic_json import write_json_atomic
48
+
49
+ CLIENTS_FILENAME = "clients.json"
50
+
51
+ #: The client kinds the gateway knows how to attribute. ``mobile`` is the
52
+ #: phone (and the principal the legacy Discovery token maps to); ``desk`` is
53
+ #: the C3 Desk tray client. Hub stays ``desktop`` and the CLI stays ``cli`` —
54
+ #: those are not gateway clients and never hold a client token.
55
+ KINDS = ("mobile", "desk")
56
+
57
+ #: The principal a request authenticated by the Discovery token gets. Kind
58
+ #: ``mobile`` keeps every existing phone's audit attribution byte-identical.
59
+ DISCOVERY_PRINCIPAL = {"kind": "mobile", "client_id": "discovery"}
60
+
61
+ _MAX_LABEL = 64
62
+ _DEFAULT_LABELS = {"mobile": "phone", "desk": "desk"}
63
+
64
+ #: Minimum seconds between two ``last_seen`` writes for one client.
65
+ LAST_SEEN_INTERVAL_S = 60.0
66
+
67
+ _lock = threading.RLock()
68
+ # client_id -> monotonic time of the last last_seen write this process made.
69
+ _last_touch: dict[str, float] = {}
70
+
71
+
72
+ def store_path() -> Path:
73
+ """``~/.c3/oracle/clients.json`` — resolved per call so tests that patch
74
+ ``Path.home`` redirect it, exactly like the override-request store."""
75
+ return Path.home() / ".c3" / "oracle" / CLIENTS_FILENAME
76
+
77
+
78
+ def _now_iso() -> str:
79
+ return datetime.now(timezone.utc).isoformat()
80
+
81
+
82
+ def hash_token(token: str) -> str:
83
+ return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
84
+
85
+
86
+ def _load() -> list[dict]:
87
+ path = store_path()
88
+ try:
89
+ raw = path.read_text(encoding="utf-8")
90
+ except OSError:
91
+ return []
92
+ try:
93
+ rows = json.loads(raw)
94
+ except ValueError:
95
+ # A corrupt store fails CLOSED for verification (no row matches) but
96
+ # must not take the whole gateway down: the Discovery token still
97
+ # authenticates, and the next mint rewrites the file whole.
98
+ return []
99
+ if not isinstance(rows, list):
100
+ return []
101
+ return [r for r in rows if isinstance(r, dict) and r.get("client_id")]
102
+
103
+
104
+ def _save(rows: list[dict]) -> None:
105
+ path = store_path()
106
+ write_json_atomic(path, rows, indent=2)
107
+ try:
108
+ os.chmod(path, 0o600)
109
+ except OSError:
110
+ pass
111
+
112
+
113
+ def _public(row: dict) -> dict:
114
+ """The row for the wire: everything except the hash. Never the token."""
115
+ return {
116
+ "client_id": row.get("client_id"),
117
+ "kind": row.get("kind"),
118
+ "label": row.get("label") or "",
119
+ "created": row.get("created"),
120
+ "last_seen": row.get("last_seen"),
121
+ "revoked_at": row.get("revoked_at"),
122
+ }
123
+
124
+
125
+ def _clean_label(kind: str, label: str | None) -> str:
126
+ text = " ".join(str(label or "").split())[:_MAX_LABEL]
127
+ return text or _DEFAULT_LABELS.get(kind, kind)
128
+
129
+
130
+ def mint(kind: str, label: str | None = None) -> tuple[dict, str]:
131
+ """Create a client. Returns ``(public_row, token)``.
132
+
133
+ The token is the ONLY copy that will ever exist in plaintext: the store
134
+ keeps its SHA-256, so a later read of ``clients.json`` (or of this
135
+ process's memory after the request returns) cannot recover it.
136
+ """
137
+ kind = str(kind or "").strip().lower()
138
+ if kind not in KINDS:
139
+ raise ValueError(f"kind must be one of {', '.join(KINDS)}")
140
+ token = secrets.token_urlsafe(32)
141
+ row = {
142
+ "client_id": f"{kind}-{secrets.token_hex(6)}",
143
+ "kind": kind,
144
+ "label": _clean_label(kind, label),
145
+ "token_hash": hash_token(token),
146
+ "created": _now_iso(),
147
+ "last_seen": None,
148
+ "revoked_at": None,
149
+ }
150
+ with _lock:
151
+ rows = _load()
152
+ rows.append(row)
153
+ _save(rows)
154
+ return _public(row), token
155
+
156
+
157
+ def verify(token: str | None) -> dict | None:
158
+ """The live row a presented token belongs to, or ``None``.
159
+
160
+ Compares against every stored hash in constant time — a revoked row is
161
+ still compared (so timing does not reveal revocation either) and only
162
+ rejected afterwards. Touches ``last_seen`` at most once per
163
+ :data:`LAST_SEEN_INTERVAL_S` per client; that write is best-effort.
164
+ """
165
+ if not token:
166
+ return None
167
+ presented = hash_token(token)
168
+ with _lock:
169
+ rows = _load()
170
+ matched: dict | None = None
171
+ for row in rows:
172
+ stored = str(row.get("token_hash") or "")
173
+ # Compare against a same-length string when the row is malformed
174
+ # so the loop cost does not depend on which rows are well-formed.
175
+ if secrets.compare_digest(presented, stored or "0" * len(presented)):
176
+ matched = row
177
+ if matched is None or matched.get("revoked_at"):
178
+ return None
179
+ cid = str(matched["client_id"])
180
+ now = time.monotonic()
181
+ if now - _last_touch.get(cid, -LAST_SEEN_INTERVAL_S) >= LAST_SEEN_INTERVAL_S:
182
+ _last_touch[cid] = now
183
+ matched["last_seen"] = _now_iso()
184
+ try:
185
+ _save(rows)
186
+ except OSError:
187
+ pass
188
+ return _public(matched)
189
+
190
+
191
+ def principal_for(token: str | None) -> dict | None:
192
+ """``{"kind", "client_id"}`` for a live client token, else ``None``.
193
+
194
+ The shape every guard stashes on ``flask.g.c3_client``; the Discovery
195
+ token's equivalent is :data:`DISCOVERY_PRINCIPAL`.
196
+ """
197
+ row = verify(token)
198
+ if row is None:
199
+ return None
200
+ return {"kind": row["kind"], "client_id": row["client_id"]}
201
+
202
+
203
+ def list_clients(include_revoked: bool = True) -> list[dict]:
204
+ """Every row, hashes stripped, oldest first."""
205
+ with _lock:
206
+ rows = _load()
207
+ out = [_public(r) for r in rows
208
+ if include_revoked or not r.get("revoked_at")]
209
+ return out
210
+
211
+
212
+ def get(client_id: str) -> dict | None:
213
+ with _lock:
214
+ for row in _load():
215
+ if row.get("client_id") == client_id:
216
+ return _public(row)
217
+ return None
218
+
219
+
220
+ def revoke(client_id: str) -> dict | None:
221
+ """Mark a client revoked. Its token fails :func:`verify` from the next
222
+ call on. Returns the public row, or ``None`` when unknown. Revoking an
223
+ already-revoked client is a no-op that returns the row."""
224
+ with _lock:
225
+ rows = _load()
226
+ for row in rows:
227
+ if row.get("client_id") == client_id:
228
+ if not row.get("revoked_at"):
229
+ row["revoked_at"] = _now_iso()
230
+ _save(rows)
231
+ _last_touch.pop(str(client_id), None)
232
+ return _public(row)
233
+ return None
234
+
235
+
236
+ def reset_touch_cache() -> None:
237
+ """Forget the per-process ``last_seen`` throttle (tests)."""
238
+ with _lock:
239
+ _last_touch.clear()
@@ -50,10 +50,10 @@ import time
50
50
  from datetime import datetime, timezone
51
51
  from pathlib import Path
52
52
 
53
- from flask import Blueprint, Response, jsonify, request
53
+ from flask import Blueprint, Response, g, jsonify, request
54
54
 
55
55
  from oracle.config import ORACLE_DIR, load_config
56
- from oracle.services import api_auth, discovery_audit
56
+ from oracle.services import api_auth, client_tokens, discovery_audit, local_session
57
57
  from oracle.services.api_auth import extract_bearer
58
58
  from oracle.services.tool_registry import _c3_version
59
59
  from services.atomic_json import write_json_atomic
@@ -66,11 +66,16 @@ from services.atomic_json import write_json_atomic
66
66
  # 4: the ops surface — /edits, /locks, /status, /insights, /suggestions,
67
67
  # /review. Reads are always on; the two capabilities that WRITE
68
68
  # (insight dismissal, suggestion approval) carry their own switches.
69
- API_VERSION = 4
69
+ # 5: per-client tokens (`clients` capability, /clients routes). A request
70
+ # is authenticated by the Discovery token OR a live client token, /info
71
+ # reports the caller as `client: {kind, client_id}`, and override
72
+ # decisions are attributed to that kind ("desk" for the tray client,
73
+ # "mobile" for a phone or the legacy Discovery token).
74
+ API_VERSION = 5
70
75
 
71
76
  CAPABILITIES = [
72
77
  "feed", "feed_wait", "projects", "health", "pm", "pm_events", "digest",
73
- "notifications_ack",
78
+ "notifications_ack", "clients",
74
79
  "credentials", "credentials_write",
75
80
  "access", "access_write",
76
81
  "enforcement", "enforcement_write",
@@ -133,9 +138,49 @@ _WAIT_TICK_S = 1.0
133
138
  # serves the desktop UI and discovery off the same pool. Past the cap `wait`
134
139
  # degrades to an ordinary immediate answer rather than queueing behind other
135
140
  # waiters. One phone needs one slot; four leaves room for the watch and a
136
- # spare.
137
- _MAX_WAITERS = 4
141
+ # spare. Since v2.125.0 the desk tray client holds a slot too, so the total
142
+ # is eight with a per-KIND cap of four: a phone that reconnects in a loop
143
+ # cannot take the desk's slots, and vice versa. The kind is the authenticated
144
+ # principal's (`g.c3_client["kind"]`), never a client-supplied field.
145
+ _MAX_WAITERS = 8
146
+ _MAX_WAITERS_PER_KIND = 4
138
147
  _waiters = threading.Semaphore(_MAX_WAITERS)
148
+ _kind_waiters: dict[str, threading.Semaphore] = {}
149
+ _kind_waiters_lock = threading.Lock()
150
+
151
+
152
+ def _kind_semaphore(kind: str) -> threading.Semaphore:
153
+ with _kind_waiters_lock:
154
+ sem = _kind_waiters.get(kind)
155
+ if sem is None:
156
+ sem = threading.Semaphore(_MAX_WAITERS_PER_KIND)
157
+ _kind_waiters[kind] = sem
158
+ return sem
159
+
160
+
161
+ def _acquire_waiter(kind: str) -> bool:
162
+ """Take one long-poll slot for *kind*. False when either the per-kind or
163
+ the global budget is spent — the caller then answers immediately."""
164
+ per_kind = _kind_semaphore(kind)
165
+ if not per_kind.acquire(blocking=False):
166
+ return False
167
+ if not _waiters.acquire(blocking=False):
168
+ per_kind.release()
169
+ return False
170
+ return True
171
+
172
+
173
+ def _release_waiter(kind: str) -> None:
174
+ _waiters.release()
175
+ _kind_semaphore(kind).release()
176
+
177
+
178
+ def _reset_waiters() -> None:
179
+ """Fresh semaphores (tests)."""
180
+ global _waiters
181
+ with _kind_waiters_lock:
182
+ _waiters = threading.Semaphore(_MAX_WAITERS)
183
+ _kind_waiters.clear()
139
184
 
140
185
  #: Files whose mtime means "the feed may have changed" — the same set
141
186
  #: ``_last_activity`` reports on, kept literal in both places on purpose. Here
@@ -177,6 +222,36 @@ def _cfg() -> dict:
177
222
 
178
223
  # ── Auth guard ────────────────────────────────────────────
179
224
 
225
+ #: The one route a client may reach WITHOUT a Bearer: it is how a client
226
+ #: obtains one. It carries its own gate (local address + bootstrap key) in
227
+ #: the handler, plus the shared rate bucket applied below.
228
+ _PAIR_ENDPOINT = "mobile.mobile_clients_mint"
229
+
230
+
231
+ def authenticate(token: str | None) -> dict | None:
232
+ """Principal for a presented Bearer, or ``None``.
233
+
234
+ The Discovery token authenticates as the legacy ``mobile``/``discovery``
235
+ principal so every phone paired before per-client tokens keeps working
236
+ and keeps its audit attribution. Otherwise the token must belong to a
237
+ live (un-revoked) row in ``clients.json``. Shared with ``chat_poll`` so
238
+ the two mobile blueprints cannot drift on what counts as a credential.
239
+ """
240
+ if not token:
241
+ return None
242
+ if api_auth.verify(token):
243
+ return dict(client_tokens.DISCOVERY_PRINCIPAL)
244
+ return client_tokens.principal_for(token)
245
+
246
+
247
+ def principal() -> dict:
248
+ """The authenticated caller for this request (set by the guard).
249
+
250
+ Falls back to the Discovery principal so a route exercised outside the
251
+ blueprint guard (a test calling a view directly) still has a kind."""
252
+ return getattr(g, "c3_client", None) or dict(client_tokens.DISCOVERY_PRINCIPAL)
253
+
254
+
180
255
  @bp.before_request
181
256
  def _mobile_auth_guard():
182
257
  """Bearer gate for the whole mobile surface — every method, GETs included.
@@ -184,14 +259,28 @@ def _mobile_auth_guard():
184
259
  Deliberately stricter than ``_discovery_auth_guard``: there is no
185
260
  ``require_auth`` opt-out and no session-cookie fallback. Mutating methods
186
261
  additionally consume from the shared Discovery rate-limit bucket.
262
+
263
+ Two credentials satisfy it (api_version 5): the Discovery token, or a
264
+ per-client token minted by ``POST /clients``. Whichever matched is
265
+ stashed as ``g.c3_client = {"kind", "client_id"}`` for the routes that
266
+ attribute what they do.
187
267
  """
188
268
  if request.method == "OPTIONS":
189
269
  return None # CORS preflight
190
270
  if not _cfg().get("mobile_api_enabled", True):
191
271
  return jsonify({"error": "mobile API disabled"}), 404
192
272
  token = extract_bearer(request.headers.get("Authorization"))
193
- if not api_auth.verify(token):
194
- return jsonify({"error": "unauthorized"}), 401
273
+ if request.endpoint == _PAIR_ENDPOINT:
274
+ # No Bearer yet by definition. The handler checks address + key; the
275
+ # rate bucket below still applies, keyed on the caller's address, so
276
+ # a key-guessing loop is throttled like any other mutation.
277
+ g.c3_client = None
278
+ token = None
279
+ else:
280
+ who = authenticate(token)
281
+ if who is None:
282
+ return jsonify({"error": "unauthorized"}), 401
283
+ g.c3_client = who
195
284
  if request.method in ("POST", "PUT", "DELETE") and _get_limiter is not None:
196
285
  limiter = _get_limiter()
197
286
  allowed, retry_after = limiter.check(
@@ -389,9 +478,101 @@ def mobile_info():
389
478
  "c3_version": _c3_version(),
390
479
  "server_time": datetime.now(timezone.utc).isoformat(),
391
480
  "capabilities": _capabilities(),
481
+ # Who the server thinks is asking (api_version 5). A client uses it
482
+ # to confirm its pairing survived and to label itself in its own UI.
483
+ "client": principal(),
392
484
  })
393
485
 
394
486
 
487
+ # ── Clients (per-device tokens, api_version 5) ───────────
488
+ #
489
+ # One Bearer per paired device, stored hashed in ~/.c3/oracle/clients.json
490
+ # (oracle/services/client_tokens.py). Minting needs proof of same-OS-user
491
+ # access to this machine — the on-disk bootstrap key, presented from a local
492
+ # address — the same boundary `POST /api/session/bootstrap` enforces for the
493
+ # dashboard cookie. The token crosses the wire exactly once, in the 201.
494
+
495
+ def _client_row_for_wire(row: dict, caller: dict | None) -> dict:
496
+ out = dict(row)
497
+ out["current"] = bool(caller and caller.get("client_id") == row.get("client_id"))
498
+ return out
499
+
500
+
501
+ @bp.route("/clients", methods=["POST"])
502
+ def mobile_clients_mint():
503
+ """Exchange the bootstrap key for a per-client token.
504
+
505
+ Body ``{"bootstrap_key": str, "kind": "desk"|"mobile", "label": str}``.
506
+ No Bearer (this is how a client obtains one); instead the request must
507
+ come from a local address AND carry the key ``~/.c3/oracle/bootstrap.key``
508
+ holds, compared in constant time. 201 ``{client_id, token, kind, label}``.
509
+ """
510
+ gate = _security_gate()
511
+ if gate:
512
+ return gate
513
+ if not local_session.is_local(request.remote_addr, _cfg().get("bind_host")):
514
+ return jsonify({"error": "local requests only"}), 403
515
+ data = request.get_json(silent=True) or {}
516
+ presented = str(data.get("bootstrap_key") or "").strip() or \
517
+ request.headers.get("X-C3-Bootstrap-Key", "")
518
+ if not local_session.verify_bootstrap_key(presented):
519
+ _gw_audit("mobile.clients.mint", {"kind": str(data.get("kind") or "")},
520
+ status="refused")
521
+ return jsonify({"error": "unauthorized"}), 401
522
+ kind = str(data.get("kind") or "").strip().lower()
523
+ if kind not in client_tokens.KINDS:
524
+ return jsonify({"error": f"kind must be one of "
525
+ f"{', '.join(client_tokens.KINDS)}"}), 400
526
+ try:
527
+ row, token = client_tokens.mint(kind, str(data.get("label") or ""))
528
+ except ValueError as exc:
529
+ return jsonify({"error": str(exc)}), 400
530
+ except Exception as exc:
531
+ return _svc_error(exc)
532
+ _gw_audit("mobile.clients.mint", {"kind": kind, "client_id": row["client_id"]})
533
+ return jsonify({
534
+ "client_id": row["client_id"],
535
+ "token": token,
536
+ "kind": row["kind"],
537
+ "label": row["label"],
538
+ "created": row["created"],
539
+ }), 201
540
+
541
+
542
+ @bp.route("/clients", methods=["GET"])
543
+ def mobile_clients_list():
544
+ """Every paired client, hashes and tokens never included. ``current``
545
+ marks the row the presenting token belongs to (false for the Discovery
546
+ token, which has no row)."""
547
+ caller = principal()
548
+ try:
549
+ rows = client_tokens.list_clients()
550
+ except Exception as exc:
551
+ return _svc_error(exc)
552
+ return jsonify({
553
+ "clients": [_client_row_for_wire(r, caller) for r in rows],
554
+ "client": caller,
555
+ })
556
+
557
+
558
+ @bp.route("/clients/<client_id>", methods=["DELETE"])
559
+ def mobile_clients_revoke(client_id):
560
+ """Revoke one client. Its token fails auth from the next request on.
561
+ Revoking yourself is allowed — that is how a client signs out."""
562
+ gate = _security_gate()
563
+ if gate:
564
+ return gate
565
+ try:
566
+ row = client_tokens.revoke(str(client_id))
567
+ except Exception as exc:
568
+ return _svc_error(exc)
569
+ if row is None:
570
+ return jsonify({"error": "unknown client"}), 404
571
+ _gw_audit("mobile.clients.revoke", {"client_id": str(client_id)})
572
+ return jsonify({"client": _client_row_for_wire(row, principal()),
573
+ "revoked": True})
574
+
575
+
395
576
  # ── Projects overview ─────────────────────────────────────
396
577
 
397
578
  def _last_activity(path: Path) -> str | None:
@@ -565,12 +746,13 @@ def _hold_for_items(targets: list, collect, wait_s: int):
565
746
  matching item — a severity this client filters out, say — re-baselines
566
747
  instead of re-scanning on every subsequent tick.
567
748
 
568
- Returns immediately when every waiter slot is taken. The caller then
569
- behaves exactly as if ``wait`` had not been passed: a slower client, not a
570
- wrong one.
749
+ Returns immediately when every waiter slot is taken — globally, or for
750
+ this caller's kind. The caller then behaves exactly as if ``wait`` had
751
+ not been passed: a slower client, not a wrong one.
571
752
  """
572
753
  started = time.monotonic()
573
- if not _waiters.acquire(blocking=False):
754
+ kind = str(principal().get("kind") or "mobile")
755
+ if not _acquire_waiter(kind):
574
756
  return [], 0.0
575
757
  try:
576
758
  seen = _feed_mtime(targets)
@@ -588,7 +770,7 @@ def _hold_for_items(targets: list, collect, wait_s: int):
588
770
  if found:
589
771
  return found, time.monotonic() - started
590
772
  finally:
591
- _waiters.release()
773
+ _release_waiter(kind)
592
774
 
593
775
 
594
776
  @bp.route("/feed")
@@ -1815,8 +1997,12 @@ _OVERRIDE_FIELDS = (
1815
1997
  "justification", "resolved_at", "decided_by", "decision_note",
1816
1998
  )
1817
1999
 
1818
- #: Decisions made here are always attributed to the phone (§3.3 decided_by).
1819
- _DECIDED_BY = "mobile"
2000
+ #: Decisions made here are attributed to the authenticated principal's kind
2001
+ #: (§3.3 decided_by): "mobile" for a phone or the legacy Discovery token,
2002
+ #: "desk" for the tray client. Never a client-supplied field — a body key
2003
+ #: cannot make a phone look like the desk.
2004
+ def _decided_by() -> str:
2005
+ return str(principal().get("kind") or "mobile")
1820
2006
 
1821
2007
 
1822
2008
  def _override_row(row: dict) -> dict:
@@ -2243,7 +2429,7 @@ def mobile_override_decide(request_id):
2243
2429
  result = orq.decide(
2244
2430
  str(request_id), decision, uses=requested_uses,
2245
2431
  ttl_s=requested_ttl, note=str(data.get("note") or ""),
2246
- decided_by=_DECIDED_BY, confirm=confirm, mode=mode,
2432
+ decided_by=_decided_by(), confirm=confirm, mode=mode,
2247
2433
  mute=bool(data.get("mute")))
2248
2434
  except orq.OverrideError as exc:
2249
2435
  _override_audit(f"{decision}_refused", row)
@@ -2313,7 +2499,7 @@ def mobile_override_mute(request_id):
2313
2499
  try:
2314
2500
  result = orq.decide(str(request_id), orq.DECISION_DENY,
2315
2501
  note=str(data.get("note") or ""),
2316
- decided_by=_DECIDED_BY, mute=True)
2502
+ decided_by=_decided_by(), mute=True)
2317
2503
  except orq.OverrideError as exc:
2318
2504
  return jsonify({"error": str(exc)}), 400
2319
2505
  except Exception as exc:
oracle/ui/app.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // ── Init ──
4
4
  // ═══════════════════════════════════════════════════════════
5
5
  (async function init() {
6
- await Promise.all([refreshHeader(), loadProjects(), loadInsights(), loadSuggestions(), loadSettings(), loadDiscoveryKey(), chatLoadConversations(), chatLoadCommands()]);
6
+ await Promise.all([refreshHeader(), loadProjects(), loadInsights(), loadSuggestions(), loadSettings(), loadDiscoveryKey(), loadPairedClients(), chatLoadConversations(), chatLoadCommands()]);
7
7
  // Restore persisted UI preferences (loadSettings cached the config).
8
8
  restoreLastTab(window.oracleConfig || {});
9
9
  // Auto-refresh every 60s
oracle/ui/settings.js CHANGED
@@ -185,12 +185,55 @@ async function _dkCopy(text, okMsg) {
185
185
 
186
186
  // ── Mobile pairing (companion app QR) ──
187
187
  // Renders {v, kind, url, token} as a QR for the C3 mobile app scanner.
188
- // The token is the Discovery Bearer key — the QR only renders on explicit
189
- // click, and never on page load.
188
+ // Since v2.125.0 the token is a PER-DEVICE client token minted by
189
+ // POST /api/pair/mobile (revealed exactly once, stored hashed), so rotating
190
+ // the Discovery token no longer un-pairs every phone and one device can be
191
+ // revoked on its own. The Discovery-token code remains as a fallback for an
192
+ // app build that predates client tokens. Nothing renders on page load.
190
193
 
191
- async function showMobilePairQr() {
194
+ function _renderPairQr(payload) {
195
+ try {
196
+ const qr = qrcode(0, 'M'); // type auto, medium error correction
197
+ qr.addData(JSON.stringify(payload));
198
+ qr.make();
199
+ document.getElementById('mpQr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0 });
200
+ } catch (e) {
201
+ toast('QR render failed', String(e), 'error');
202
+ return false;
203
+ }
204
+ document.getElementById('mpQrWrap').style.display = '';
205
+ document.getElementById('mpHideBtn').style.display = '';
206
+ return true;
207
+ }
208
+
209
+ function _pairUrl() {
192
210
  const urlInput = document.getElementById('mpUrl');
193
211
  if (!urlInput.value.trim()) urlInput.value = window.location.origin;
212
+ return urlInput.value.trim().replace(/\/+$/, '');
213
+ }
214
+
215
+ async function showMobilePairQr() {
216
+ const url = _pairUrl();
217
+ const answer = prompt('Name this device (shown in the paired-devices list):', 'phone');
218
+ if (answer === null) return; // cancelled — mint nothing
219
+ const label = answer.trim() || 'phone';
220
+ let minted;
221
+ try {
222
+ minted = await api('/api/pair/mobile', { method: 'POST', body: { label: label || 'phone' } });
223
+ } catch (e) {
224
+ toast('Pairing failed', e.message || String(e), 'error');
225
+ return;
226
+ }
227
+ if (_renderPairQr({ v: 1, kind: 'c3-oracle', url, token: minted.token })) {
228
+ const note = document.getElementById('mpQrNote');
229
+ if (note) note.textContent = `Device token for "${minted.label}" (${minted.client_id}) — shown once. Revoke it below if the phone is lost.`;
230
+ toast('Pairing code ready', `Scan it now; the token for "${minted.label}" is not shown again.`, 'success', 4000);
231
+ }
232
+ loadPairedClients();
233
+ }
234
+
235
+ async function showDiscoveryPairQr() {
236
+ const url = _pairUrl();
194
237
  let s = window._discoveryKey;
195
238
  if (!s) { await loadDiscoveryKey(); s = window._discoveryKey; }
196
239
  if (!s || !s.exists) {
@@ -201,23 +244,10 @@ async function showMobilePairQr() {
201
244
  toast('Token unavailable', 'Open the dashboard via "c3 oracle open" so this session may reveal the token.', 'warning');
202
245
  return;
203
246
  }
204
- const payload = JSON.stringify({
205
- v: 1,
206
- kind: 'c3-oracle',
207
- url: urlInput.value.trim().replace(/\/+$/, ''),
208
- token: s.key,
209
- });
210
- try {
211
- const qr = qrcode(0, 'M'); // type auto, medium error correction
212
- qr.addData(payload);
213
- qr.make();
214
- document.getElementById('mpQr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0 });
215
- } catch (e) {
216
- toast('QR render failed', String(e), 'error');
217
- return;
247
+ if (_renderPairQr({ v: 1, kind: 'c3-oracle', url, token: s.key })) {
248
+ const note = document.getElementById('mpQrNote');
249
+ if (note) note.textContent = 'Discovery token (legacy) — rotating it un-pairs every phone that scanned this.';
218
250
  }
219
- document.getElementById('mpQrWrap').style.display = '';
220
- document.getElementById('mpHideBtn').style.display = '';
221
251
  }
222
252
 
223
253
  function hideMobilePairQr() {
@@ -226,4 +256,30 @@ function hideMobilePairQr() {
226
256
  document.getElementById('mpHideBtn').style.display = 'none';
227
257
  }
228
258
 
259
+ // ── Paired devices (client tokens) ──
260
+
261
+ async function loadPairedClients() {
262
+ const el = document.getElementById('mpClients');
263
+ if (!el) return;
264
+ let rows;
265
+ try { rows = (await api('/api/pair/clients')).clients || []; }
266
+ catch { el.innerHTML = '<div style="font-size:11px;color:var(--text2)">Sign in (c3 oracle open) to list paired devices.</div>'; return; }
267
+ const live = rows.filter(r => !r.revoked_at);
268
+ if (!live.length) { el.innerHTML = '<div style="font-size:11px;color:var(--text2)">No paired devices yet.</div>'; return; }
269
+ el.innerHTML = live.map(r => `
270
+ <div style="display:flex;align-items:center;gap:10px;padding:6px 0;border-top:1px solid var(--border);font-size:12px">
271
+ <span style="font-family:monospace;color:var(--text2)">${esc(r.kind)}</span>
272
+ <span style="flex:1">${esc(r.label || r.client_id)} <span style="color:var(--text2);font-size:11px">· ${esc(r.client_id)}${r.last_seen ? ' · seen ' + esc(r.last_seen.slice(0, 16).replace('T', ' ')) : ' · never used'}</span></span>
273
+ <button class="btn btn-ghost" style="font-size:11px" onclick="revokePairedClient('${esc(r.client_id)}')">Revoke</button>
274
+ </div>`).join('');
275
+ }
276
+
277
+ async function revokePairedClient(clientId) {
278
+ if (!confirm(`Revoke ${clientId}? That device stops working on its next request.`)) return;
279
+ await tracked('Revoking device', async () => {
280
+ await api('/api/pair/clients/' + encodeURIComponent(clientId), { method: 'DELETE' });
281
+ await loadPairedClients();
282
+ }, { successMsg: 'Device revoked' });
283
+ }
284
+
229
285
  // ═══════════════════════════════════════════════════════════