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 +1 -1
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/METADATA +2 -2
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/RECORD +16 -14
- oracle/config.py +5 -0
- oracle/listeners.py +210 -0
- oracle/oracle_server.py +88 -2
- oracle/oracle_ui.html +9 -3
- oracle/services/chat_poll.py +8 -3
- oracle/services/client_tokens.py +239 -0
- oracle/services/mobile_api.py +203 -17
- oracle/ui/app.js +1 -1
- oracle/ui/settings.js +75 -19
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
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
|
|
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=
|
|
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.
|
|
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=
|
|
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=
|
|
138
|
-
oracle/oracle_ui.html,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
310
|
-
code_context_control-2.
|
|
311
|
-
code_context_control-2.
|
|
312
|
-
code_context_control-2.
|
|
313
|
-
code_context_control-2.
|
|
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
|
-
|
|
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
|
|
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"
|
|
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>
|
oracle/services/chat_poll.py
CHANGED
|
@@ -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
|
-
|
|
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()
|
oracle/services/mobile_api.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
194
|
-
|
|
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
|
|
569
|
-
behaves exactly as if ``wait`` had
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1819
|
-
|
|
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=
|
|
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=
|
|
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
|
-
//
|
|
189
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════
|
|
File without changes
|
{code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.124.0.dist-info → code_context_control-2.125.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|