code-context-control 2.145.2__py3-none-any.whl → 2.149.1__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 +3 -3
- cli/hook_access_guard.py +1 -0
- cli/hub_server.py +259 -43
- cli/hub_ui/app.js +4 -1
- cli/hub_ui/components/hub_access.js +119 -2
- cli/hub_ui/components/hub_credentials.js +21 -14
- cli/hub_ui/components/modals.js +34 -12
- cli/hub_ui/components/settings_modal.js +11 -0
- cli/hub_ui/state.js +7 -3
- cli/hub_ui.html +1 -1
- cli/mcp_server.py +11 -1
- cli/server.py +4 -4
- cli/tools/credentials.py +1 -2
- cli/ui.html +1 -1
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/METADATA +1 -1
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/RECORD +29 -28
- services/access_guard.py +60 -4
- services/activity_log.py +43 -15
- services/builtin_leases.py +182 -0
- services/ci_act.py +22 -1
- services/ci_workflow.py +5 -2
- services/credential_backup.py +9 -3
- services/credential_store.py +72 -10
- services/project_manager.py +19 -2
- services/session_catalog.py +82 -34
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/WHEEL +0 -0
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.145.2.dist-info → code_context_control-2.149.1.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
|
|
|
91
91
|
# Config
|
|
92
92
|
CONFIG_DIR = ".c3"
|
|
93
93
|
CONFIG_FILE = ".c3/config.json"
|
|
94
|
-
__version__ = "2.
|
|
94
|
+
__version__ = "2.149.1"
|
|
95
95
|
|
|
96
96
|
# The PreToolUse matcher for native subagent calls (installer and hub migration).
|
|
97
97
|
AGENT_MATCHER = "Agent|Task"
|
|
@@ -6685,10 +6685,10 @@ def _creds_cmd_list(args, project_path: str) -> None:
|
|
|
6685
6685
|
"(add --global for all projects).")
|
|
6686
6686
|
return
|
|
6687
6687
|
print(f"{len(entries)} credential(s) — project scope shadows global:")
|
|
6688
|
+
present = cred_store.presence_for(entries, project_path)
|
|
6688
6689
|
missing = 0
|
|
6689
6690
|
for name, entry in entries.items():
|
|
6690
|
-
gone = not
|
|
6691
|
-
name, project_path=project_path, scope=entry["scope"])
|
|
6691
|
+
gone = not present[name]
|
|
6692
6692
|
missing += gone
|
|
6693
6693
|
print(" " + _creds_entry_line(name, entry, gone))
|
|
6694
6694
|
if missing:
|
cli/hook_access_guard.py
CHANGED
|
@@ -447,6 +447,7 @@ def run(payload: dict, project_path: Path | None = None,
|
|
|
447
447
|
tool_input = payload.get("tool_input", {}) or {}
|
|
448
448
|
base = str(project_path if project_path is not None else Path.cwd())
|
|
449
449
|
session_id = str(payload.get("session_id") or "")
|
|
450
|
+
ag.bind_session(session_id)
|
|
450
451
|
|
|
451
452
|
if tool in _WRITE_TOOLS or tool in _READ_TOOLS:
|
|
452
453
|
fp = _target(tool_input)
|
cli/hub_server.py
CHANGED
|
@@ -72,6 +72,10 @@ _HUB_CONFIG_DEFAULTS = {
|
|
|
72
72
|
"projects_view": "list",
|
|
73
73
|
"main_view": "projects",
|
|
74
74
|
"oracle_url": "",
|
|
75
|
+
# Per-IDE launch command overrides, e.g. {"claude-code": "yep"} when the
|
|
76
|
+
# agent is started through a wrapper script instead of the stock binary.
|
|
77
|
+
# A project's own `ide_cmd` (projects.json) wins over this.
|
|
78
|
+
"ide_cmds": {},
|
|
75
79
|
# Minutes of project silence, with no session heartbeat left, before a UI
|
|
76
80
|
# server that an IDE session launched is stopped. 0 = never reap.
|
|
77
81
|
"ui_reap_minutes": 30,
|
|
@@ -81,6 +85,48 @@ _HUB_CONFIG_DEFAULTS = {
|
|
|
81
85
|
_MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access",
|
|
82
86
|
"enforce", "sessions"}
|
|
83
87
|
|
|
88
|
+
# Stock command per IDE id, and whether it takes the project path as an
|
|
89
|
+
# argument (GUI) instead of being run inside a terminal at that path.
|
|
90
|
+
_IDE_DEFAULT_CMDS = {
|
|
91
|
+
"claude-code": ("claude", False),
|
|
92
|
+
"claude-app": ("claude-app", True),
|
|
93
|
+
"codex": ("codex", False),
|
|
94
|
+
"grok": ("grok", False),
|
|
95
|
+
"gemini": ("gemini", False),
|
|
96
|
+
"antigravity": ("antigravity", False),
|
|
97
|
+
"vscode": ("code", True),
|
|
98
|
+
"cursor": ("cursor", True),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_ide_cmd(ide: str, path: str, custom_cmd: str = "") -> tuple:
|
|
103
|
+
"""Resolve the command to launch for `ide` in `path`.
|
|
104
|
+
|
|
105
|
+
Precedence: explicit custom_cmd (this launch) → the project's stored
|
|
106
|
+
`ide_cmd` → hub config `ide_cmds[ide]` → the stock command.
|
|
107
|
+
Returns (cmd, is_gui, error) — error is a message when nothing resolves.
|
|
108
|
+
"""
|
|
109
|
+
default_cmd, is_gui = _IDE_DEFAULT_CMDS.get(ide, ("", False))
|
|
110
|
+
if ide != "custom" and not default_cmd:
|
|
111
|
+
return "", False, f"Unknown IDE: {ide}"
|
|
112
|
+
cmd = (custom_cmd or "").strip()
|
|
113
|
+
if not cmd:
|
|
114
|
+
try:
|
|
115
|
+
cmd = _pm().get_ide_cmd(path)
|
|
116
|
+
except Exception:
|
|
117
|
+
cmd = ""
|
|
118
|
+
if not cmd:
|
|
119
|
+
overrides = _read_hub_config().get("ide_cmds") or {}
|
|
120
|
+
if isinstance(overrides, dict):
|
|
121
|
+
cmd = str(overrides.get(ide) or "").strip()
|
|
122
|
+
if not cmd:
|
|
123
|
+
cmd = default_cmd
|
|
124
|
+
if not cmd:
|
|
125
|
+
return "", False, "custom_cmd is required for custom IDE"
|
|
126
|
+
# An overridden command replaces the binary, not the launch style: a
|
|
127
|
+
# wrapper for a terminal CLI is still a terminal CLI.
|
|
128
|
+
return cmd, is_gui, ""
|
|
129
|
+
|
|
84
130
|
# How often the hub reaps orphaned UI servers and chases autostart projects.
|
|
85
131
|
_UI_SWEEP_INTERVAL_S = 60
|
|
86
132
|
|
|
@@ -460,6 +506,15 @@ def api_hub_config_set():
|
|
|
460
506
|
cfg["main_view"] = main_view
|
|
461
507
|
if "oracle_url" in data:
|
|
462
508
|
cfg["oracle_url"] = str(data["oracle_url"]).strip()
|
|
509
|
+
if "ide_cmds" in data:
|
|
510
|
+
raw = data["ide_cmds"]
|
|
511
|
+
if not isinstance(raw, dict):
|
|
512
|
+
return jsonify({"error": "ide_cmds must be an object"}), 400
|
|
513
|
+
# Empty string = "back to the stock command", so the key is dropped
|
|
514
|
+
# rather than stored blank.
|
|
515
|
+
cfg["ide_cmds"] = {
|
|
516
|
+
str(k): str(v).strip() for k, v in raw.items() if str(v or "").strip()
|
|
517
|
+
}
|
|
463
518
|
if "sidebar_group" in data:
|
|
464
519
|
cfg["sidebar_group"] = str(data["sidebar_group"]).strip()
|
|
465
520
|
if "sidebar_collapsed" in data:
|
|
@@ -718,17 +773,11 @@ def api_projects_open():
|
|
|
718
773
|
|
|
719
774
|
@app.route("/api/projects/launch-ide", methods=["POST"])
|
|
720
775
|
def api_launch_ide():
|
|
721
|
-
"""Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
"grok": ("grok", False),
|
|
727
|
-
"gemini": ("gemini", False),
|
|
728
|
-
"antigravity": ("antigravity", False),
|
|
729
|
-
"vscode": ("code", True),
|
|
730
|
-
"cursor": ("cursor", True),
|
|
731
|
-
}
|
|
776
|
+
"""Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}
|
|
777
|
+
|
|
778
|
+
The command itself is resolved by _resolve_ide_cmd, so a project or the
|
|
779
|
+
hub can point `claude-code` at a wrapper such as `yep`.
|
|
780
|
+
"""
|
|
732
781
|
try:
|
|
733
782
|
data = request.get_json(force=True) or {}
|
|
734
783
|
path_str = (data.get("path") or "").strip()
|
|
@@ -744,14 +793,9 @@ def api_launch_ide():
|
|
|
744
793
|
if not path.exists():
|
|
745
794
|
return jsonify({"error": f"Path does not exist: {path_str}"}), 404
|
|
746
795
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
cmd, is_gui = custom_cmd, False
|
|
751
|
-
elif ide in _IDE_CMDS:
|
|
752
|
-
cmd, is_gui = _IDE_CMDS[ide]
|
|
753
|
-
else:
|
|
754
|
-
return jsonify({"error": f"Unknown IDE: {ide}"}), 400
|
|
796
|
+
cmd, is_gui, err = _resolve_ide_cmd(ide, str(path), custom_cmd)
|
|
797
|
+
if err:
|
|
798
|
+
return jsonify({"error": err}), 400
|
|
755
799
|
|
|
756
800
|
if is_gui:
|
|
757
801
|
# GUI IDEs (VS Code, Cursor) accept a path argument directly
|
|
@@ -795,7 +839,7 @@ def api_launch_ide():
|
|
|
795
839
|
|
|
796
840
|
@app.route("/api/projects/update", methods=["POST"])
|
|
797
841
|
def api_projects_update():
|
|
798
|
-
"""Update editable project fields
|
|
842
|
+
"""Update editable project fields. Body: {path, name?, tags?, notes?, ide_cmd?}"""
|
|
799
843
|
data = request.get_json(force=True) or {}
|
|
800
844
|
path = (data.get("path") or "").strip()
|
|
801
845
|
if not path:
|
|
@@ -812,6 +856,9 @@ def api_projects_update():
|
|
|
812
856
|
fields["tags"] = [t.strip() for t in str(raw).split(",") if t.strip()]
|
|
813
857
|
if "notes" in data:
|
|
814
858
|
fields["notes"] = str(data["notes"])
|
|
859
|
+
if "ide_cmd" in data:
|
|
860
|
+
# Blank clears the override and falls back to the stock command.
|
|
861
|
+
fields["ide_cmd"] = str(data["ide_cmd"] or "").strip()
|
|
815
862
|
try:
|
|
816
863
|
ok = _pm().update_project(path, **fields)
|
|
817
864
|
return jsonify({"updated": ok})
|
|
@@ -1804,7 +1851,7 @@ def api_projects_browse():
|
|
|
1804
1851
|
return jsonify({"error": str(e)}), 400
|
|
1805
1852
|
if not resolved.is_dir():
|
|
1806
1853
|
return jsonify({"error": f"Not a directory: {resolved}"}), 404
|
|
1807
|
-
registered = {os.path.normcase(p.get("path", "")) for p in _pm().
|
|
1854
|
+
registered = {os.path.normcase(p.get("path", "")) for p in _pm().list_registered()}
|
|
1808
1855
|
dirs = []
|
|
1809
1856
|
try:
|
|
1810
1857
|
for child in sorted(resolved.iterdir(), key=lambda c: c.name.lower()):
|
|
@@ -1913,7 +1960,7 @@ def api_search_global():
|
|
|
1913
1960
|
if wanted else None)
|
|
1914
1961
|
|
|
1915
1962
|
candidates, skipped = [], []
|
|
1916
|
-
for p in _pm().
|
|
1963
|
+
for p in _pm().list_registered():
|
|
1917
1964
|
ppath = p.get("path") or ""
|
|
1918
1965
|
if wanted_norm is not None and os.path.normcase(ppath) not in wanted_norm:
|
|
1919
1966
|
continue
|
|
@@ -2138,14 +2185,15 @@ def api_projects_credentials():
|
|
|
2138
2185
|
usage = cred_store.read_usage_state(str(resolved))
|
|
2139
2186
|
home = cred_store.global_base()
|
|
2140
2187
|
global_names = set(cred_store.list_entries(str(home))) if home else set()
|
|
2188
|
+
listed = cred_store.list_entries(str(resolved))
|
|
2189
|
+
present = cred_store.presence_for(listed, str(resolved))
|
|
2141
2190
|
entries = []
|
|
2142
|
-
for name, entry in
|
|
2191
|
+
for name, entry in listed.items():
|
|
2143
2192
|
entries.append(_cred_entry_public(
|
|
2144
2193
|
name, entry, usage=usage,
|
|
2145
2194
|
shadows_global=(entry.get("scope") == "project"
|
|
2146
2195
|
and name in global_names),
|
|
2147
|
-
value_ok=
|
|
2148
|
-
name, project_path=str(resolved), scope=entry["scope"])))
|
|
2196
|
+
value_ok=present[name]))
|
|
2149
2197
|
return jsonify({"path": str(resolved), "entries": entries})
|
|
2150
2198
|
|
|
2151
2199
|
|
|
@@ -2360,7 +2408,7 @@ def api_hub_credentials_audit():
|
|
|
2360
2408
|
only_global = (request.args.get("scope") or "").strip().lower() == "global"
|
|
2361
2409
|
|
|
2362
2410
|
events, errors = [], []
|
|
2363
|
-
for p in ([] if only_global else _pm().
|
|
2411
|
+
for p in ([] if only_global else _pm().list_registered()):
|
|
2364
2412
|
ppath = str(p.get("path") or "")
|
|
2365
2413
|
if not (Path(ppath) / ".c3").is_dir():
|
|
2366
2414
|
continue
|
|
@@ -2417,9 +2465,12 @@ def api_hub_credentials_overview():
|
|
|
2417
2465
|
from services import credential_store as cred_store
|
|
2418
2466
|
home = cred_store.global_base()
|
|
2419
2467
|
global_entries = cred_store.list_entries(str(home)) if home else {}
|
|
2468
|
+
# realm -> {name: value decodes}, probed once per vault and shared with
|
|
2469
|
+
# the backup banner, which asks the same question of the same entries.
|
|
2470
|
+
present = {"global": cred_store.value_presence("global", str(home))} if home else {}
|
|
2420
2471
|
shadowed_in = {name: [] for name in global_entries}
|
|
2421
2472
|
projects_out = []
|
|
2422
|
-
for p in _pm().
|
|
2473
|
+
for p in _pm().list_registered():
|
|
2423
2474
|
ppath = str(p.get("path") or "")
|
|
2424
2475
|
row = {"name": p.get("name") or "", "path": ppath,
|
|
2425
2476
|
"initialized": True, "error": None, "entries": []}
|
|
@@ -2428,14 +2479,15 @@ def api_hub_credentials_overview():
|
|
|
2428
2479
|
row["initialized"] = False
|
|
2429
2480
|
else:
|
|
2430
2481
|
usage = cred_store.read_usage_state(ppath)
|
|
2482
|
+
ok = cred_store.value_presence("project", ppath)
|
|
2483
|
+
present[cred_store.realm("project", ppath)] = ok
|
|
2431
2484
|
for name, entry in cred_store.list_entries(ppath).items():
|
|
2432
2485
|
if entry.get("scope") != "project":
|
|
2433
2486
|
continue
|
|
2434
2487
|
row["entries"].append(_cred_entry_public(
|
|
2435
2488
|
name, entry, usage=usage,
|
|
2436
2489
|
shadows_global=name in global_entries,
|
|
2437
|
-
value_ok=
|
|
2438
|
-
name, project_path=ppath, scope="project")))
|
|
2490
|
+
value_ok=ok.get(name, False)))
|
|
2439
2491
|
if name in shadowed_in:
|
|
2440
2492
|
shadowed_in[name].append(
|
|
2441
2493
|
{"name": row["name"], "path": ppath})
|
|
@@ -2446,21 +2498,21 @@ def api_hub_credentials_overview():
|
|
|
2446
2498
|
global_out = [
|
|
2447
2499
|
{**_cred_entry_public(
|
|
2448
2500
|
name, entry, usage=global_usage,
|
|
2449
|
-
value_ok=
|
|
2450
|
-
name, project_path=str(home), scope="global")),
|
|
2501
|
+
value_ok=present.get("global", {}).get(name, False)),
|
|
2451
2502
|
"shadowed_in": shadowed_in.get(name, [])}
|
|
2452
2503
|
for name, entry in global_entries.items()
|
|
2453
2504
|
]
|
|
2454
2505
|
return jsonify({"global": {"entries": global_out}, "projects": projects_out,
|
|
2455
2506
|
"backup": _cred_backup_summary(
|
|
2456
|
-
[row["path"] for row in projects_out if row["initialized"]]
|
|
2507
|
+
[row["path"] for row in projects_out if row["initialized"]],
|
|
2508
|
+
present)})
|
|
2457
2509
|
|
|
2458
2510
|
|
|
2459
|
-
def _cred_backup_summary(project_paths) -> dict:
|
|
2511
|
+
def _cred_backup_summary(project_paths, present) -> dict:
|
|
2460
2512
|
"""Backup state as counts for the Hub banner; a broken file reports its error."""
|
|
2461
2513
|
from services import credential_backup
|
|
2462
2514
|
try:
|
|
2463
|
-
st = credential_backup.status(project_paths)
|
|
2515
|
+
st = credential_backup.status(project_paths, presence=present)
|
|
2464
2516
|
except credential_backup.BackupError as exc:
|
|
2465
2517
|
return {"enabled": True, "error": str(exc), "restorable": 0, "lost": 0}
|
|
2466
2518
|
return {"enabled": st["enabled"], "restorable": len(st["restorable"]),
|
|
@@ -2612,7 +2664,7 @@ def api_hub_tokens_overview():
|
|
|
2612
2664
|
days = _hub_tokens_days(request.args.get("days"))
|
|
2613
2665
|
rows, totals = [], {"tool_calls": 0, "tool_tokens": 0,
|
|
2614
2666
|
"session_tokens": 0, "saved": 0}
|
|
2615
|
-
for p in _pm().
|
|
2667
|
+
for p in _pm().list_registered():
|
|
2616
2668
|
ppath = str(p.get("path") or "")
|
|
2617
2669
|
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
|
|
2618
2670
|
"error": None, "tool_calls": 0, "tool_tokens": 0,
|
|
@@ -2662,7 +2714,7 @@ def api_hub_locks_overview():
|
|
|
2662
2714
|
from services import agent_locks as al
|
|
2663
2715
|
|
|
2664
2716
|
rows, total = [], 0
|
|
2665
|
-
for p in _pm().
|
|
2717
|
+
for p in _pm().list_registered():
|
|
2666
2718
|
ppath = str(p.get("path") or "")
|
|
2667
2719
|
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
|
|
2668
2720
|
"error": None, "enabled": True, "mode": "advisory",
|
|
@@ -2752,7 +2804,7 @@ def api_hub_enforcement_overview():
|
|
|
2752
2804
|
|
|
2753
2805
|
rows = []
|
|
2754
2806
|
totals = {"discipline": 0, "access": 0}
|
|
2755
|
-
for p in _pm().
|
|
2807
|
+
for p in _pm().list_registered():
|
|
2756
2808
|
ppath = str(p.get("path") or "")
|
|
2757
2809
|
row = {"name": p.get("name") or "", "path": ppath, "initialized": True,
|
|
2758
2810
|
"error": None, "mode": None, "scope": "", "set_by": "",
|
|
@@ -3428,7 +3480,7 @@ def api_hub_override_policy_overview():
|
|
|
3428
3480
|
from services import override_policy as opol
|
|
3429
3481
|
|
|
3430
3482
|
rows = []
|
|
3431
|
-
for p in _pm().
|
|
3483
|
+
for p in _pm().list_registered():
|
|
3432
3484
|
ppath = str(p.get("path") or "")
|
|
3433
3485
|
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
|
|
3434
3486
|
"initialized": True, "error": None, "policy": None,
|
|
@@ -3740,7 +3792,7 @@ def api_hub_access_overview():
|
|
|
3740
3792
|
empty = {"deny": [], "read_only": [], "confirm": [], "mask": 0,
|
|
3741
3793
|
"corrupt": False}
|
|
3742
3794
|
rows = []
|
|
3743
|
-
for p in _pm().
|
|
3795
|
+
for p in _pm().list_registered():
|
|
3744
3796
|
ppath = str(p.get("path") or "")
|
|
3745
3797
|
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
|
|
3746
3798
|
"initialized": True, "error": None, "rules": dict(empty)}
|
|
@@ -3834,6 +3886,170 @@ def api_hub_access_rule_remove():
|
|
|
3834
3886
|
return jsonify(result)
|
|
3835
3887
|
|
|
3836
3888
|
|
|
3889
|
+
# ── Builtin guard modes (v2.147.0) ─────────────────────────────────────────
|
|
3890
|
+
|
|
3891
|
+
def _hub_human():
|
|
3892
|
+
"""The Desk client behind ``Authorization: Bearer``, else ``None``.
|
|
3893
|
+
|
|
3894
|
+
Loopback and the CSRF guard stop a browser, not a process on this
|
|
3895
|
+
machine: an agent can POST here as easily as a person. A Desk client
|
|
3896
|
+
token is minted only with ``bootstrap.key``, which Access Guard denies
|
|
3897
|
+
to agents, and is stored as a hash, so presenting one is the proof a
|
|
3898
|
+
loosening builtin change needs. A phone token does not count: builtin
|
|
3899
|
+
guards are not loosened from a device that leaves the building.
|
|
3900
|
+
"""
|
|
3901
|
+
from oracle.services import client_tokens
|
|
3902
|
+
scheme, _, token = (request.headers.get("Authorization") or "").partition(" ")
|
|
3903
|
+
if scheme.lower() != "bearer" or not token.strip():
|
|
3904
|
+
return None
|
|
3905
|
+
who = client_tokens.principal_for(token.strip())
|
|
3906
|
+
return who if who and who.get("kind") == "desk" else None
|
|
3907
|
+
|
|
3908
|
+
|
|
3909
|
+
@app.route("/api/hub/access/builtin", methods=["GET"])
|
|
3910
|
+
def api_hub_access_builtin():
|
|
3911
|
+
"""Every Tier-1 builtin with its live global mode, and each project's own.
|
|
3912
|
+
|
|
3913
|
+
Modes are read through the keyring (config and attestation must agree),
|
|
3914
|
+
so a mode listed here is one evaluation honours. ``projects`` holds only
|
|
3915
|
+
projects that set a mode of their own, or could not be read.
|
|
3916
|
+
``strictness`` ranks each mode 0 (allow) to 3 (full deny), so a client
|
|
3917
|
+
can tell loosening from tightening without knowing the tiers.
|
|
3918
|
+
"""
|
|
3919
|
+
try:
|
|
3920
|
+
global_modes = access_guard._scope_modes("global")
|
|
3921
|
+
except Exception as e:
|
|
3922
|
+
return jsonify({"error": str(e)}), 500
|
|
3923
|
+
home = access_guard._global_base()
|
|
3924
|
+
guards = []
|
|
3925
|
+
for glob in access_guard.DISABLEABLE_BUILTINS:
|
|
3926
|
+
canon = access_guard._norm_builtin(glob)
|
|
3927
|
+
guards.append({
|
|
3928
|
+
"glob": canon,
|
|
3929
|
+
"global": global_modes.get(canon, "default"),
|
|
3930
|
+
"strictness": {m: access_guard.builtin_strictness(canon, m)
|
|
3931
|
+
for m in (*access_guard.BUILTIN_MODES, "default")},
|
|
3932
|
+
})
|
|
3933
|
+
projects = []
|
|
3934
|
+
for p in _pm().list_registered():
|
|
3935
|
+
ppath = str(p.get("path") or "")
|
|
3936
|
+
row = {"name": p.get("name") or Path(ppath).name, "path": ppath,
|
|
3937
|
+
"modes": {}, "error": None}
|
|
3938
|
+
try:
|
|
3939
|
+
base = Path(ppath).resolve()
|
|
3940
|
+
if (base / ".c3").is_dir() and base != home:
|
|
3941
|
+
row["modes"] = access_guard._scope_modes("project", ppath)
|
|
3942
|
+
except Exception as e:
|
|
3943
|
+
row["error"] = str(e)
|
|
3944
|
+
if row["modes"] or row["error"]:
|
|
3945
|
+
projects.append(row)
|
|
3946
|
+
from services import builtin_leases
|
|
3947
|
+
return jsonify({"guards": guards, "projects": projects,
|
|
3948
|
+
"leases": builtin_leases.active()})
|
|
3949
|
+
|
|
3950
|
+
|
|
3951
|
+
@app.route("/api/hub/access/builtin/mode", methods=["POST"])
|
|
3952
|
+
def api_hub_access_builtin_mode():
|
|
3953
|
+
"""Set a Tier-1 builtin's mode in the global realm or one project's.
|
|
3954
|
+
|
|
3955
|
+
Body: ``{scope, path?, glob, mode, confirm?}``. A mode at least as strict
|
|
3956
|
+
as the live one needs nothing more. A looser one needs a Desk client
|
|
3957
|
+
token (403 ``needs_human`` without it) and ``confirm`` equal to the glob
|
|
3958
|
+
(400 ``needs_confirmation``). A project is judged against what its
|
|
3959
|
+
agents see today, which is the global mode unless it set its own.
|
|
3960
|
+
"""
|
|
3961
|
+
data = request.get_json(silent=True) or {}
|
|
3962
|
+
scope, project, err = _hub_access_target(data)
|
|
3963
|
+
if err:
|
|
3964
|
+
return err
|
|
3965
|
+
glob = str(data.get("glob") or "")
|
|
3966
|
+
mode = str(data.get("mode") or "").strip()
|
|
3967
|
+
canon = access_guard._norm_builtin(glob)
|
|
3968
|
+
try:
|
|
3969
|
+
new = access_guard.builtin_strictness(canon, mode)
|
|
3970
|
+
live = (access_guard._scope_modes("global") if scope == "global"
|
|
3971
|
+
else access_guard.effective_builtin_modes(project))
|
|
3972
|
+
old = access_guard.builtin_strictness(canon, live.get(canon, "default"))
|
|
3973
|
+
except ValueError as exc:
|
|
3974
|
+
return jsonify({"error": str(exc)}), 400
|
|
3975
|
+
if new < old:
|
|
3976
|
+
if _hub_human() is None:
|
|
3977
|
+
return jsonify({
|
|
3978
|
+
"error": "loosening a built-in guard needs C3 Desk",
|
|
3979
|
+
"needs_human": True,
|
|
3980
|
+
"command": f'c3 access builtin mode "{canon}" {mode}'
|
|
3981
|
+
+ (f' --project --path "{project}"'
|
|
3982
|
+
if scope == "project" else ""),
|
|
3983
|
+
}), 403
|
|
3984
|
+
if access_guard._norm_builtin(data.get("confirm")) != canon:
|
|
3985
|
+
return jsonify({
|
|
3986
|
+
"error": "loosening a built-in guard weakens protection",
|
|
3987
|
+
"needs_confirmation": True, "confirm_with": canon,
|
|
3988
|
+
}), 400
|
|
3989
|
+
try:
|
|
3990
|
+
result = access_guard.set_builtin_mode(canon, mode, scope,
|
|
3991
|
+
project or ".")
|
|
3992
|
+
except ValueError as exc:
|
|
3993
|
+
return jsonify({"error": str(exc)}), 400
|
|
3994
|
+
if result["changed"]:
|
|
3995
|
+
_hub_access_audit(f"builtin_mode_{result['mode']}", result["glob"],
|
|
3996
|
+
"builtin", scope, project)
|
|
3997
|
+
return jsonify({**result, "loosened": new < old})
|
|
3998
|
+
|
|
3999
|
+
|
|
4000
|
+
@app.route("/api/hub/access/builtin/lease", methods=["POST"])
|
|
4001
|
+
def api_hub_access_builtin_lease():
|
|
4002
|
+
"""Loosen a builtin for a while: every session, or one.
|
|
4003
|
+
|
|
4004
|
+
Body: ``{scope, path?, glob, mode, ttl_s, session_id?, confirm}``. A
|
|
4005
|
+
lease only ever loosens, so it always needs a Desk client token and the
|
|
4006
|
+
glob typed back, and a lease no looser than the live mode is a 400
|
|
4007
|
+
rather than a row that would never apply. ``ttl_s`` is 60 s to 8 h.
|
|
4008
|
+
"""
|
|
4009
|
+
from services import builtin_leases
|
|
4010
|
+
data = request.get_json(silent=True) or {}
|
|
4011
|
+
scope, project, err = _hub_access_target(data)
|
|
4012
|
+
if err:
|
|
4013
|
+
return err
|
|
4014
|
+
who = _hub_human()
|
|
4015
|
+
if who is None:
|
|
4016
|
+
return jsonify({"error": "a temporary built-in change needs C3 Desk",
|
|
4017
|
+
"needs_human": True}), 403
|
|
4018
|
+
canon = access_guard._norm_builtin(data.get("glob"))
|
|
4019
|
+
mode = str(data.get("mode") or "").strip()
|
|
4020
|
+
if access_guard._norm_builtin(data.get("confirm")) != canon or not canon:
|
|
4021
|
+
return jsonify({"error": "a temporary change weakens protection",
|
|
4022
|
+
"needs_confirmation": True, "confirm_with": canon}), 400
|
|
4023
|
+
try:
|
|
4024
|
+
live = access_guard.effective_builtin_modes(project or ".") \
|
|
4025
|
+
if scope == "project" else access_guard._scope_modes("global")
|
|
4026
|
+
if access_guard.builtin_strictness(canon, mode) >= \
|
|
4027
|
+
access_guard.builtin_strictness(canon, live.get(canon, "default")):
|
|
4028
|
+
return jsonify({"error": f"{canon} is already {live.get(canon, 'default')}"
|
|
4029
|
+
f" — a {mode} lease would change nothing"}), 400
|
|
4030
|
+
row = builtin_leases.mint(
|
|
4031
|
+
canon, mode, scope=scope, project_path=project or ".",
|
|
4032
|
+
session_id=str(data.get("session_id") or ""),
|
|
4033
|
+
ttl_s=data.get("ttl_s"), created_by=who["client_id"])
|
|
4034
|
+
except ValueError as exc:
|
|
4035
|
+
return jsonify({"error": str(exc)}), 400
|
|
4036
|
+
_hub_access_audit(f"builtin_lease_{mode}", canon, "builtin", scope, project)
|
|
4037
|
+
return jsonify({"lease": row})
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
@app.route("/api/hub/access/builtin/lease/revoke", methods=["POST"])
|
|
4041
|
+
def api_hub_access_builtin_lease_revoke():
|
|
4042
|
+
"""End a lease now. Tightening, so no token. Body: ``{id}``."""
|
|
4043
|
+
from services import builtin_leases
|
|
4044
|
+
lease_id = str((request.get_json(silent=True) or {}).get("id") or "")
|
|
4045
|
+
gone = builtin_leases.revoke(lease_id) if lease_id else None
|
|
4046
|
+
if gone is None:
|
|
4047
|
+
return jsonify({"error": "unknown or already-expired lease"}), 404
|
|
4048
|
+
_hub_access_audit("builtin_lease_revoke", gone["glob"], "builtin",
|
|
4049
|
+
gone["scope"], gone.get("project_path") or None)
|
|
4050
|
+
return jsonify({"revoked": lease_id})
|
|
4051
|
+
|
|
4052
|
+
|
|
3837
4053
|
# ── Project management: tasks / milestones / notes (v2.45.0) ──────────────
|
|
3838
4054
|
# Direct TaskStore per request (reload-per-op store — no runtime build needed
|
|
3839
4055
|
# for mutations); every write audited to the target project's activity log.
|
|
@@ -4268,7 +4484,7 @@ def api_pm_global():
|
|
|
4268
4484
|
from services.task_store import TaskStore
|
|
4269
4485
|
|
|
4270
4486
|
tasks, skipped, by_project = [], [], {}
|
|
4271
|
-
entries = _pm().
|
|
4487
|
+
entries = _pm().list_registered()
|
|
4272
4488
|
for p in entries:
|
|
4273
4489
|
ppath = p.get("path") or ""
|
|
4274
4490
|
if not Path(ppath).is_dir():
|
|
@@ -4808,7 +5024,7 @@ def _session_project(raw: str):
|
|
|
4808
5024
|
if not raw:
|
|
4809
5025
|
return None
|
|
4810
5026
|
want = os.path.normcase(os.path.normpath(raw))
|
|
4811
|
-
for p in _pm().
|
|
5027
|
+
for p in _pm().list_registered():
|
|
4812
5028
|
path = str(p.get("path") or "")
|
|
4813
5029
|
if path and os.path.normcase(os.path.normpath(path)) == want:
|
|
4814
5030
|
return {"name": str(p.get("name") or Path(path).name), "path": path}
|
|
@@ -4847,7 +5063,7 @@ def api_hub_sessions_overview():
|
|
|
4847
5063
|
return row
|
|
4848
5064
|
|
|
4849
5065
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
4850
|
-
rows = list(pool.map(one, _pm().
|
|
5066
|
+
rows = list(pool.map(one, _pm().list_registered()))
|
|
4851
5067
|
rows.sort(key=lambda r: r.get("last_active") or "", reverse=True)
|
|
4852
5068
|
return jsonify({"projects": rows, "features": _SESSIONS_FEATURES})
|
|
4853
5069
|
|
|
@@ -4876,7 +5092,7 @@ def api_hub_sessions_list():
|
|
|
4876
5092
|
res["errors"] = []
|
|
4877
5093
|
else:
|
|
4878
5094
|
projects = [{"name": str(p.get("name") or ""), "path": str(p.get("path") or "")}
|
|
4879
|
-
for p in _pm().
|
|
5095
|
+
for p in _pm().list_registered()]
|
|
4880
5096
|
res = sc.list_many(projects, stale=stale, q=q, limit=limit, before=before)
|
|
4881
5097
|
return jsonify(res)
|
|
4882
5098
|
except Exception as e:
|
cli/hub_ui/app.js
CHANGED
|
@@ -50,7 +50,10 @@ function App() {
|
|
|
50
50
|
}, []);
|
|
51
51
|
|
|
52
52
|
useEffect(() => { loadConfig(); loadProjects(); }, []);
|
|
53
|
-
|
|
53
|
+
// Live status matters on the project list and in a drill-in. Elsewhere it
|
|
54
|
+
// only refreshes the sidebar and top-bar counts, which may lag 30 s, and
|
|
55
|
+
// each poll reads every project's activity log.
|
|
56
|
+
usePoll(loadProjects, mainView === 'projects' || drill ? 5000 : 30000);
|
|
54
57
|
|
|
55
58
|
// Persisted preferences
|
|
56
59
|
const toggleTheme = () => {
|