netizen-cli 0.10.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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Immutable session configuration intent shared by Bindings and plans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from .domain import MentionContextMode
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .bindings import ThreadBinding
|
|
12
|
+
from .model_settings import ModelCatalog
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionSettingsError(ValueError):
|
|
16
|
+
code = "invalid_session_settings"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class BindingTurnSettings:
|
|
21
|
+
"""Catalog selection to apply to every new Turn started by Netizen."""
|
|
22
|
+
|
|
23
|
+
model_id: str
|
|
24
|
+
effort_id: str
|
|
25
|
+
service_tier_id: str
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
values = (self.model_id, self.effort_id, self.service_tier_id)
|
|
29
|
+
if not all(isinstance(value, str) and value for value in values):
|
|
30
|
+
raise ValueError("Binding Turn settings IDs must not be empty")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class BindingTaskFeedback:
|
|
35
|
+
"""Binding-scoped pulse/card/completion feedback choices for Turns."""
|
|
36
|
+
|
|
37
|
+
reaction_pulse_enabled: bool = False
|
|
38
|
+
progress_card_enabled: bool = False
|
|
39
|
+
completion_mention_enabled: bool = True
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
values = (self.reaction_pulse_enabled, self.progress_card_enabled, self.completion_mention_enabled)
|
|
43
|
+
if not all(type(value) is bool for value in values):
|
|
44
|
+
raise ValueError("Binding task feedback values must be booleans")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_FIELDS = frozenset({"turn_settings", "reaction_pulse_enabled", "progress_card_enabled", "completion_mention_enabled", "message_context_mode"})
|
|
48
|
+
_TURN_FIELDS = frozenset({"model_id", "effort_id", "service_tier_id"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class SessionSettings:
|
|
53
|
+
turn_settings: BindingTurnSettings | None = None
|
|
54
|
+
task_feedback: BindingTaskFeedback = BindingTaskFeedback()
|
|
55
|
+
message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY
|
|
56
|
+
|
|
57
|
+
def __post_init__(self) -> None:
|
|
58
|
+
if self.turn_settings is not None and not isinstance(self.turn_settings, BindingTurnSettings):
|
|
59
|
+
raise SessionSettingsError("turn_settings 必须是完整模型设置或 null。")
|
|
60
|
+
if not isinstance(self.task_feedback, BindingTaskFeedback):
|
|
61
|
+
raise SessionSettingsError("执行反馈必须是明确的布尔设置。")
|
|
62
|
+
if not isinstance(self.message_context_mode, MentionContextMode):
|
|
63
|
+
raise SessionSettingsError("消息上下文必须是 current-only 或 catch-up。")
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"turn_settings": asdict(self.turn_settings) if self.turn_settings else None,
|
|
68
|
+
"reaction_pulse_enabled": self.task_feedback.reaction_pulse_enabled,
|
|
69
|
+
"progress_card_enabled": self.task_feedback.progress_card_enabled,
|
|
70
|
+
"completion_mention_enabled": self.task_feedback.completion_mention_enabled,
|
|
71
|
+
"message_context_mode": self.message_context_mode.value,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_dict(cls, value: dict[str, Any]) -> SessionSettings:
|
|
76
|
+
if not isinstance(value, dict) or set(value) != _FIELDS:
|
|
77
|
+
raise SessionSettingsError("会话设置必须包含全部五个配置字段,且不能包含其他字段。")
|
|
78
|
+
raw_turn = value["turn_settings"]
|
|
79
|
+
turn = None
|
|
80
|
+
if raw_turn is not None:
|
|
81
|
+
if not isinstance(raw_turn, dict) or set(raw_turn) != _TURN_FIELDS:
|
|
82
|
+
raise SessionSettingsError("模型设置需要 model_id、effort_id、service_tier_id,或用 null 继承 Codex。")
|
|
83
|
+
try:
|
|
84
|
+
turn = BindingTurnSettings(**raw_turn)
|
|
85
|
+
except ValueError as error:
|
|
86
|
+
raise SessionSettingsError("模型、思考强度和速度 ID 必须是非空字符串。") from error
|
|
87
|
+
try:
|
|
88
|
+
feedback = BindingTaskFeedback(
|
|
89
|
+
value["reaction_pulse_enabled"], value["progress_card_enabled"], value["completion_mention_enabled"],
|
|
90
|
+
)
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
raise SessionSettingsError("执行中表情闪烁、进度卡和结束时 @ 提醒必须是布尔值。") from error
|
|
93
|
+
try:
|
|
94
|
+
mode = MentionContextMode(value["message_context_mode"])
|
|
95
|
+
except (ValueError, TypeError) as error:
|
|
96
|
+
raise SessionSettingsError("消息上下文必须是 current-only 或 catch-up。") from error
|
|
97
|
+
return cls(turn, feedback, mode)
|
|
98
|
+
|
|
99
|
+
def merge(self, partial: dict[str, Any]) -> SessionSettings:
|
|
100
|
+
if not isinstance(partial, dict) or set(partial) - _FIELDS:
|
|
101
|
+
raise SessionSettingsError("会话设置更新必须是仅包含支持字段的对象。")
|
|
102
|
+
return self.from_dict({**self.to_dict(), **partial})
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_binding(cls, binding: ThreadBinding) -> SessionSettings:
|
|
106
|
+
# All nested objects are immutable. Do not copy the Binding's context
|
|
107
|
+
# anchor: each new session must establish its own message boundary.
|
|
108
|
+
return cls(binding.turn_settings, binding.task_feedback, binding.message_context_mode)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def new_defaults(cls, catalog: ModelCatalog | None) -> SessionSettings:
|
|
112
|
+
feedback = BindingTaskFeedback(progress_card_enabled=True)
|
|
113
|
+
if catalog is None:
|
|
114
|
+
return cls(task_feedback=feedback)
|
|
115
|
+
model = catalog.default_model
|
|
116
|
+
return cls(turn_settings=BindingTurnSettings(
|
|
117
|
+
model.id, model.default_effort_id, model.default_service_tier_id,
|
|
118
|
+
), task_feedback=feedback)
|
|
119
|
+
|
|
120
|
+
def validate_catalog(self, catalog: ModelCatalog) -> None:
|
|
121
|
+
if self.turn_settings is not None:
|
|
122
|
+
catalog.resolve(
|
|
123
|
+
model_id=self.turn_settings.model_id,
|
|
124
|
+
effort_id=self.turn_settings.effort_id,
|
|
125
|
+
service_tier_id=self.turn_settings.service_tier_id,
|
|
126
|
+
)
|
netizen_cli/settings.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Small YAML configuration and protected Lark application profile loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import ipaddress
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .lark_app import LARK_APP_RELATIVE_PATH, LarkAppConfigError, load_lark_app
|
|
16
|
+
from .admin.port_config import ConfigFileSnapshot
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SettingsError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class AdminWebSettings:
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
host: str = "0.0.0.0"
|
|
27
|
+
port: int | None = None
|
|
28
|
+
access_host: str | None = None
|
|
29
|
+
credential_path: Path | None = field(default=None, repr=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Settings:
|
|
34
|
+
app_id: str
|
|
35
|
+
app_secret: str = field(repr=False)
|
|
36
|
+
project_root: Path
|
|
37
|
+
data_dir: Path = Path(".netizen-data")
|
|
38
|
+
projects: dict[str, Path] = field(default_factory=dict)
|
|
39
|
+
security_mode: str = "audit"
|
|
40
|
+
admin_web: AdminWebSettings = AdminWebSettings()
|
|
41
|
+
config_path: Path | None = None
|
|
42
|
+
config_snapshot: ConfigFileSnapshot | None = field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_file(
|
|
46
|
+
cls,
|
|
47
|
+
path: str | Path,
|
|
48
|
+
environment: Mapping[str, str] | None = None,
|
|
49
|
+
) -> "Settings":
|
|
50
|
+
env = os.environ if environment is None else environment
|
|
51
|
+
config_path = Path(path).expanduser().resolve(strict=True)
|
|
52
|
+
config_snapshot = ConfigFileSnapshot.read(config_path)
|
|
53
|
+
try:
|
|
54
|
+
loaded = yaml.safe_load(config_snapshot.content)
|
|
55
|
+
except yaml.YAMLError as error:
|
|
56
|
+
raise SettingsError(f"invalid YAML: {error}") from error
|
|
57
|
+
if not isinstance(loaded, dict):
|
|
58
|
+
raise SettingsError("configuration root must be a mapping")
|
|
59
|
+
if "access" in loaded:
|
|
60
|
+
raise SettingsError(
|
|
61
|
+
"access allowlists are not supported; configure availability "
|
|
62
|
+
"in the Feishu app console"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
instance = _mapping(loaded, "instance")
|
|
66
|
+
channel = _optional_mapping(loaded, "channel")
|
|
67
|
+
admin_web = _admin_web_settings(loaded, env)
|
|
68
|
+
raw_projects = loaded.get("projects", {})
|
|
69
|
+
if not isinstance(raw_projects, dict):
|
|
70
|
+
raise SettingsError("projects must map aliases to absolute paths")
|
|
71
|
+
|
|
72
|
+
if env.get("FEISHU_APP_SECRET") or env.get("FEISHU_APP_SECRET_FILE"):
|
|
73
|
+
raise SettingsError(
|
|
74
|
+
"Feishu secret environment sources are no longer supported; use NETIZEN_LARK_APP_CONFIG"
|
|
75
|
+
)
|
|
76
|
+
credential_path = env.get("NETIZEN_LARK_APP_CONFIG", "").strip()
|
|
77
|
+
try:
|
|
78
|
+
credentials = load_lark_app(
|
|
79
|
+
Path(credential_path) if credential_path
|
|
80
|
+
else config_path.parent / LARK_APP_RELATIVE_PATH
|
|
81
|
+
)
|
|
82
|
+
except LarkAppConfigError as error:
|
|
83
|
+
raise SettingsError(str(error)) from None
|
|
84
|
+
data_dir = _absolute_path(instance, "dataDir")
|
|
85
|
+
project_root = _absolute_path(instance, "projectRoot")
|
|
86
|
+
projects: dict[str, Path] = {}
|
|
87
|
+
for alias, value in raw_projects.items():
|
|
88
|
+
if not isinstance(alias, str) or not isinstance(value, str):
|
|
89
|
+
raise SettingsError("projects must map string aliases to string paths")
|
|
90
|
+
project_path = Path(value).expanduser()
|
|
91
|
+
if not project_path.is_absolute():
|
|
92
|
+
raise SettingsError(f"Project {alias} path must be absolute")
|
|
93
|
+
projects[alias] = project_path
|
|
94
|
+
|
|
95
|
+
security_mode = channel.get("securityMode", "audit")
|
|
96
|
+
if not isinstance(security_mode, str) or security_mode not in {
|
|
97
|
+
"compat",
|
|
98
|
+
"audit",
|
|
99
|
+
"strict",
|
|
100
|
+
}:
|
|
101
|
+
raise SettingsError("channel.securityMode must be compat, audit, or strict")
|
|
102
|
+
|
|
103
|
+
return cls(
|
|
104
|
+
app_id=credentials.app_id,
|
|
105
|
+
app_secret=credentials.app_secret,
|
|
106
|
+
data_dir=data_dir,
|
|
107
|
+
project_root=project_root,
|
|
108
|
+
projects=projects,
|
|
109
|
+
security_mode=security_mode,
|
|
110
|
+
admin_web=admin_web,
|
|
111
|
+
config_path=config_path,
|
|
112
|
+
config_snapshot=config_snapshot,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _mapping(parent: Mapping[str, Any], name: str) -> Mapping[str, Any]:
|
|
117
|
+
value = parent.get(name)
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
raise SettingsError(f"{name} must be a mapping")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _optional_mapping(parent: Mapping[str, Any], name: str) -> Mapping[str, Any]:
|
|
124
|
+
if name not in parent:
|
|
125
|
+
return {}
|
|
126
|
+
return _mapping(parent, name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _string(parent: Mapping[str, Any], name: str) -> str:
|
|
130
|
+
value = parent.get(name)
|
|
131
|
+
if not isinstance(value, str) or not value.strip():
|
|
132
|
+
raise SettingsError(f"{name} must be a non-empty string")
|
|
133
|
+
return value.strip()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _absolute_path(parent: Mapping[str, Any], name: str) -> Path:
|
|
137
|
+
value = Path(_string(parent, name)).expanduser()
|
|
138
|
+
if not value.is_absolute():
|
|
139
|
+
raise SettingsError(f"{name} must be an absolute path")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _admin_web_settings(
|
|
144
|
+
loaded: Mapping[str, Any],
|
|
145
|
+
environment: Mapping[str, str],
|
|
146
|
+
) -> AdminWebSettings:
|
|
147
|
+
raw_secret = environment.get("NETIZEN_ADMIN_SECRET", "")
|
|
148
|
+
if raw_secret:
|
|
149
|
+
raise SettingsError(
|
|
150
|
+
"NETIZEN_ADMIN_SECRET is not supported; use "
|
|
151
|
+
"NETIZEN_ADMIN_SECRET_FILE"
|
|
152
|
+
)
|
|
153
|
+
values = _optional_mapping(loaded, "adminWeb")
|
|
154
|
+
unknown = values.keys() - {"enabled", "host", "port", "accessHost"}
|
|
155
|
+
if unknown:
|
|
156
|
+
raise SettingsError("adminWeb contains unsupported settings")
|
|
157
|
+
enabled = values.get("enabled", True)
|
|
158
|
+
if not isinstance(enabled, bool):
|
|
159
|
+
raise SettingsError("adminWeb.enabled must be a boolean")
|
|
160
|
+
host = values.get("host", "0.0.0.0")
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(host, str)
|
|
163
|
+
or not host
|
|
164
|
+
or host.strip() != host
|
|
165
|
+
or any(ord(character) < 0x20 for character in host)
|
|
166
|
+
):
|
|
167
|
+
raise SettingsError("adminWeb.host must be a non-empty host")
|
|
168
|
+
port = values.get("port")
|
|
169
|
+
if "port" in values and (
|
|
170
|
+
isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535
|
|
171
|
+
):
|
|
172
|
+
raise SettingsError("adminWeb.port must be an integer from 1 to 65535")
|
|
173
|
+
access_host = validate_access_host(values["accessHost"]) if "accessHost" in values else None
|
|
174
|
+
raw_path = environment.get("NETIZEN_ADMIN_SECRET_FILE", "").strip()
|
|
175
|
+
credential_path: Path | None = None
|
|
176
|
+
if raw_path:
|
|
177
|
+
credential_path = Path(raw_path)
|
|
178
|
+
if not credential_path.is_absolute():
|
|
179
|
+
raise SettingsError("NETIZEN_ADMIN_SECRET_FILE must be an absolute path")
|
|
180
|
+
elif enabled:
|
|
181
|
+
raise SettingsError(
|
|
182
|
+
"NETIZEN_ADMIN_SECRET_FILE is required when Admin Web is enabled"
|
|
183
|
+
)
|
|
184
|
+
return AdminWebSettings(
|
|
185
|
+
enabled=enabled,
|
|
186
|
+
host=host,
|
|
187
|
+
port=port,
|
|
188
|
+
access_host=access_host,
|
|
189
|
+
credential_path=credential_path,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def validate_access_host(value: object) -> str:
|
|
194
|
+
"""A hostname or bare IP only; never a URL, port, or wildcard listener."""
|
|
195
|
+
|
|
196
|
+
message = "adminWeb.accessHost must be a hostname or IP without scheme, port, path, or wildcard"
|
|
197
|
+
if not isinstance(value, str) or not value or value.strip() != value:
|
|
198
|
+
raise SettingsError(message)
|
|
199
|
+
if any(character in value for character in "/\\@?#[]%"):
|
|
200
|
+
raise SettingsError(message)
|
|
201
|
+
try:
|
|
202
|
+
address = ipaddress.ip_address(value)
|
|
203
|
+
except ValueError:
|
|
204
|
+
try:
|
|
205
|
+
hostname = value.removesuffix(".").encode("idna").decode("ascii").lower()
|
|
206
|
+
except UnicodeError:
|
|
207
|
+
raise SettingsError(message) from None
|
|
208
|
+
if len(hostname) > 253 or any(
|
|
209
|
+
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
210
|
+
for label in hostname.split(".")
|
|
211
|
+
):
|
|
212
|
+
raise SettingsError(message)
|
|
213
|
+
return hostname
|
|
214
|
+
if address.is_unspecified:
|
|
215
|
+
raise SettingsError(message)
|
|
216
|
+
return address.compressed
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Parse the explicit, current-message Skill reference prefix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_SKILL_REFERENCE = re.compile(r"\$([A-Za-z0-9][A-Za-z0-9_.:-]{0,127})(?=\s|$)")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidSkillReference(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_skill_references(text: str) -> tuple[str, ...]:
|
|
16
|
+
"""Return the leading ``$name`` block, preserving user order.
|
|
17
|
+
|
|
18
|
+
Restricting activation to a leading reference block avoids interpreting
|
|
19
|
+
shell variables, prose, or code later in a prompt as executable Skills.
|
|
20
|
+
The caller must parse only the current user message, never quoted/history
|
|
21
|
+
material.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
position = 0
|
|
25
|
+
length = len(text)
|
|
26
|
+
while position < length and text[position].isspace():
|
|
27
|
+
position += 1
|
|
28
|
+
names: list[str] = []
|
|
29
|
+
while position < length and text[position] == "$":
|
|
30
|
+
match = _SKILL_REFERENCE.match(text, position)
|
|
31
|
+
if match is None:
|
|
32
|
+
break
|
|
33
|
+
name = match.group(1)
|
|
34
|
+
if name in names:
|
|
35
|
+
raise InvalidSkillReference(f"Skill ${name} 在同一条消息中重复引用。")
|
|
36
|
+
names.append(name)
|
|
37
|
+
position = match.end()
|
|
38
|
+
while position < length and text[position].isspace():
|
|
39
|
+
position += 1
|
|
40
|
+
return tuple(names)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Version-gated access to fixed background-terminal App Server methods.
|
|
2
|
+
|
|
3
|
+
This module is intentionally the only place for the experimental terminal
|
|
4
|
+
inspection/cleanup compatibility contract. It is not a general JSON-RPC adapter: the
|
|
5
|
+
method name, request shape, response shape, SDK version, and relevant SDK
|
|
6
|
+
implementation fingerprints are all fixed here. ADR 0014's independently
|
|
7
|
+
removable Goal/Skills facade-gap adapters do not weaken this gate.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
import openai_codex
|
|
17
|
+
from openai_codex import AsyncCodex
|
|
18
|
+
from openai_codex.async_client import AsyncCodexClient
|
|
19
|
+
from openai_codex.client import CodexClient
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SUPPORTED_SDK_VERSION = "0.156.1"
|
|
24
|
+
_CLEAN_METHOD = "thread/backgroundTerminals/clean"
|
|
25
|
+
_LIST_METHOD = "thread/backgroundTerminals/list"
|
|
26
|
+
_PACKAGE_SOURCE_FINGERPRINT = (
|
|
27
|
+
"7d0a2267e45d39934c64d2c01bddde8b36eacb2d771dd6903a056c6abef8e94f"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedCleanupSdk(RuntimeError):
|
|
32
|
+
"""The installed SDK no longer matches the explicitly approved shim."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TerminalCleanup(Protocol):
|
|
36
|
+
"""Request cleanup of App Server-registered background terminals only."""
|
|
37
|
+
|
|
38
|
+
async def clean_thread(self, thread_id: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BackgroundTerminalInspector(Protocol):
|
|
42
|
+
"""Report only whether App Server has a registered terminal for a Thread."""
|
|
43
|
+
|
|
44
|
+
async def has_running(self, thread_id: str) -> bool: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _CleanupResponse(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _BackgroundTerminalListResponse(BaseModel):
|
|
52
|
+
"""Minimal envelope; terminal identities never leave this adapter."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
|
|
56
|
+
data: tuple[dict[str, object], ...]
|
|
57
|
+
next_cursor: str | None = Field(default=None, alias="nextCursor")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PinnedExperimentalTerminalCleanup:
|
|
61
|
+
"""Inspect or clean registered background terminals for one exact Thread.
|
|
62
|
+
|
|
63
|
+
Construction validates the private ownership edge before the service can
|
|
64
|
+
accept a Turn. The underlying low-level client's typed ``request`` method
|
|
65
|
+
is public, but the high-level ``AsyncCodex._client`` reference is not; that
|
|
66
|
+
single reach-through is the narrow, temporary compatibility shim approved
|
|
67
|
+
in ADR 0009. A successful empty response attests only that the request was
|
|
68
|
+
accepted; it does not attest that a foreground tool process exited (ADR
|
|
69
|
+
0010).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
__slots__ = ("_client",)
|
|
73
|
+
|
|
74
|
+
def __init__(self, codex: AsyncCodex) -> None:
|
|
75
|
+
_validate_contract(codex)
|
|
76
|
+
self._client = codex._client
|
|
77
|
+
|
|
78
|
+
async def clean_thread(self, thread_id: str) -> None:
|
|
79
|
+
_validate_thread_id(thread_id)
|
|
80
|
+
await self._client.request(
|
|
81
|
+
_CLEAN_METHOD,
|
|
82
|
+
{"threadId": thread_id},
|
|
83
|
+
response_model=_CleanupResponse,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def has_running(self, thread_id: str) -> bool:
|
|
87
|
+
"""Return a presence bit without exposing process metadata to Runtime."""
|
|
88
|
+
|
|
89
|
+
_validate_thread_id(thread_id)
|
|
90
|
+
response = await self._client.request(
|
|
91
|
+
_LIST_METHOD,
|
|
92
|
+
{"threadId": thread_id, "limit": 1},
|
|
93
|
+
response_model=_BackgroundTerminalListResponse,
|
|
94
|
+
)
|
|
95
|
+
# A non-empty cursor with no first-page item is an unexpected shape;
|
|
96
|
+
# conservatively report presence so Runtime cannot unsubscribe.
|
|
97
|
+
return bool(response.data or response.next_cursor)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_thread_id(thread_id: str) -> None:
|
|
101
|
+
if not thread_id or thread_id.strip() != thread_id:
|
|
102
|
+
raise ValueError("native Thread ID must be a non-empty trimmed string")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_contract(codex: AsyncCodex) -> None:
|
|
106
|
+
if openai_codex.__version__ != SUPPORTED_SDK_VERSION:
|
|
107
|
+
raise UnsupportedCleanupSdk(
|
|
108
|
+
"experimental terminal cleanup supports only openai-codex=="
|
|
109
|
+
f"{SUPPORTED_SDK_VERSION}; found {openai_codex.__version__}"
|
|
110
|
+
)
|
|
111
|
+
if type(codex) is not AsyncCodex:
|
|
112
|
+
raise UnsupportedCleanupSdk("AsyncCodex implementation type changed")
|
|
113
|
+
if getattr(codex, "_initialized", False) is not True:
|
|
114
|
+
raise UnsupportedCleanupSdk(
|
|
115
|
+
"AsyncCodex must be initialized before terminal cleanup is enabled"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
client = getattr(codex, "_client", None)
|
|
119
|
+
if type(client) is not AsyncCodexClient:
|
|
120
|
+
raise UnsupportedCleanupSdk("AsyncCodex private client shape changed")
|
|
121
|
+
sync_client = getattr(client, "_sync", None)
|
|
122
|
+
if type(sync_client) is not CodexClient:
|
|
123
|
+
raise UnsupportedCleanupSdk("AsyncCodexClient private sync client shape changed")
|
|
124
|
+
if getattr(getattr(sync_client, "config", None), "experimental_api", None) is not True:
|
|
125
|
+
raise UnsupportedCleanupSdk(
|
|
126
|
+
"Codex App Server experimentalApi capability is not enabled"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
package_file = getattr(openai_codex, "__file__", None)
|
|
130
|
+
if not isinstance(package_file, str) or not package_file.endswith(".py"):
|
|
131
|
+
raise UnsupportedCleanupSdk("cannot locate SDK source package")
|
|
132
|
+
try:
|
|
133
|
+
actual = _source_tree_fingerprint(Path(package_file).resolve().parent)
|
|
134
|
+
except OSError as error:
|
|
135
|
+
raise UnsupportedCleanupSdk("cannot read SDK source package") from error
|
|
136
|
+
if actual != _PACKAGE_SOURCE_FINGERPRINT:
|
|
137
|
+
raise UnsupportedCleanupSdk("SDK package source fingerprint changed")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _source_tree_fingerprint(package_root: Path) -> str:
|
|
141
|
+
source_files = sorted(
|
|
142
|
+
(path for path in package_root.rglob("*.py") if path.is_file()),
|
|
143
|
+
key=lambda path: path.relative_to(package_root).as_posix(),
|
|
144
|
+
)
|
|
145
|
+
if not source_files:
|
|
146
|
+
raise OSError("SDK source package contains no Python files")
|
|
147
|
+
digest = hashlib.sha256()
|
|
148
|
+
for source_path in source_files:
|
|
149
|
+
relative = source_path.relative_to(package_root).as_posix().encode("utf-8")
|
|
150
|
+
source = source_path.read_bytes()
|
|
151
|
+
digest.update(len(relative).to_bytes(4, "big"))
|
|
152
|
+
digest.update(relative)
|
|
153
|
+
digest.update(len(source).to_bytes(8, "big"))
|
|
154
|
+
digest.update(source)
|
|
155
|
+
return digest.hexdigest()
|