coderio 0.4.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.
Files changed (97) hide show
  1. coderio/__init__.py +14 -0
  2. coderio/agent/__init__.py +5 -0
  3. coderio/agent/_deepagents_compat.py +139 -0
  4. coderio/agent/command_review.py +140 -0
  5. coderio/agent/deep_loop.py +758 -0
  6. coderio/agent/harness.py +768 -0
  7. coderio/agent/harness_middleware.py +293 -0
  8. coderio/agent/permission_middleware.py +52 -0
  9. coderio/agent/prompts.py +304 -0
  10. coderio/agent/skill_tool.py +70 -0
  11. coderio/agent/state.py +144 -0
  12. coderio/agent/stream.py +93 -0
  13. coderio/cli/__init__.py +0 -0
  14. coderio/cli/app.py +257 -0
  15. coderio/cli/commands.py +339 -0
  16. coderio/cli/credentials.py +70 -0
  17. coderio/cli/mcp_cmd.py +227 -0
  18. coderio/cli/multimodal.py +94 -0
  19. coderio/cli/onboarding.py +270 -0
  20. coderio/cli/providers.py +113 -0
  21. coderio/cli/render.py +28 -0
  22. coderio/cli/repl.py +189 -0
  23. coderio/cli/run_cmd.py +171 -0
  24. coderio/cli/skills_cmd.py +77 -0
  25. coderio/cli/stream.py +224 -0
  26. coderio/cli/tui.py +1584 -0
  27. coderio/cli/tui_onboarding.py +465 -0
  28. coderio/cli/tui_screens.py +334 -0
  29. coderio/cli/tui_widgets.py +453 -0
  30. coderio/config/__init__.py +21 -0
  31. coderio/config/bootstrap.py +38 -0
  32. coderio/config/loader.py +273 -0
  33. coderio/config/models.py +193 -0
  34. coderio/config/trust.py +136 -0
  35. coderio/llm/__init__.py +3 -0
  36. coderio/llm/factory.py +164 -0
  37. coderio/llm/probe.py +153 -0
  38. coderio/mcp_loader.py +274 -0
  39. coderio/session/__init__.py +3 -0
  40. coderio/session/message.py +79 -0
  41. coderio/session/store.py +244 -0
  42. coderio/skills/__init__.py +4 -0
  43. coderio/skills/bundled/executing-plans/SKILL.md +15 -0
  44. coderio/skills/lion-skills/LICENSE +21 -0
  45. coderio/skills/lion-skills/README.md +114 -0
  46. coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
  47. coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
  48. coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
  49. coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
  50. coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
  51. coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
  52. coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
  53. coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
  54. coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
  55. coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
  56. coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
  57. coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
  58. coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
  59. coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
  60. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
  61. coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
  62. coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
  63. coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
  64. coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
  65. coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
  66. coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
  67. coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
  68. coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
  69. coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
  70. coderio/skills/models.py +66 -0
  71. coderio/skills/parser.py +50 -0
  72. coderio/skills/store.py +102 -0
  73. coderio/tools/__init__.py +94 -0
  74. coderio/tools/base.py +55 -0
  75. coderio/tools/bash.py +168 -0
  76. coderio/tools/command_policy.py +364 -0
  77. coderio/tools/edit_file.py +55 -0
  78. coderio/tools/glob_tool.py +21 -0
  79. coderio/tools/grep_tool.py +90 -0
  80. coderio/tools/linux_sandbox.py +196 -0
  81. coderio/tools/list_dir.py +58 -0
  82. coderio/tools/multi_edit.py +62 -0
  83. coderio/tools/note.py +72 -0
  84. coderio/tools/permission.py +214 -0
  85. coderio/tools/read_file.py +51 -0
  86. coderio/tools/sandbox_runner.py +113 -0
  87. coderio/tools/todo.py +71 -0
  88. coderio/tools/web_fetch.py +188 -0
  89. coderio/tools/web_search.py +49 -0
  90. coderio/tools/win_job.py +214 -0
  91. coderio/tools/win_sandbox.py +639 -0
  92. coderio/tools/write_file.py +25 -0
  93. coderio-0.4.0.dist-info/METADATA +490 -0
  94. coderio-0.4.0.dist-info/RECORD +97 -0
  95. coderio-0.4.0.dist-info/WHEEL +4 -0
  96. coderio-0.4.0.dist-info/entry_points.txt +2 -0
  97. coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import replace
6
+ from pathlib import Path
7
+
8
+ from coderio.config.models import (
9
+ CliConfig,
10
+ Config,
11
+ ContextConfig,
12
+ ModelConfig,
13
+ Profile,
14
+ SandboxFsConfig,
15
+ SessionConfig,
16
+ SkillsConfig,
17
+ ToolsConfig,
18
+ )
19
+
20
+
21
+ def _read_toml(path: Path) -> dict:
22
+ if path.is_file():
23
+ with open(path, "rb") as f:
24
+ return tomllib.load(f)
25
+ return {}
26
+
27
+
28
+ def _merge(base: dict, override: dict) -> dict:
29
+ """Shallow-merge per section; override wins at the key level."""
30
+ out = {k: dict(v) if isinstance(v, dict) else v for k, v in base.items()}
31
+ for k, v in override.items():
32
+ if isinstance(v, dict):
33
+ out.setdefault(k, {})
34
+ out[k].update(v)
35
+ else:
36
+ out[k] = v
37
+ return out
38
+
39
+
40
+ def _find_project_dir(search_from: Path) -> Path:
41
+ """Walk upward from search_from looking for a project's .coderio/config.toml.
42
+
43
+ Never ascends into the user's home directory (the home ~/.coderio is the USER
44
+ layer, handled separately), so a temp dir nested under home won't accidentally
45
+ pick up the real user config as if it were a project config.
46
+ """
47
+ cur = search_from.resolve()
48
+ home = Path(os.path.expanduser("~")).resolve()
49
+ for parent in [cur, *cur.parents]:
50
+ if parent == home:
51
+ break
52
+ if (parent / ".coderio" / "config.toml").is_file():
53
+ return parent
54
+ return search_from.resolve()
55
+
56
+
57
+ def _default_user_dir() -> Path:
58
+ return Path(os.path.expanduser("~")) / ".coderio"
59
+
60
+
61
+ def _parse_profiles(data: dict) -> list:
62
+ """Parse the [[profiles]] array into Profile objects.
63
+
64
+ Each table element must have at least name + provider_id + model. base_url
65
+ and kind fall back to "" and "openai_compatible". Malformed entries (missing
66
+ required fields) are skipped rather than crashing — a typo in one profile
67
+ shouldn't prevent the whole config from loading.
68
+ """
69
+ raw = data.get("profiles")
70
+ if not isinstance(raw, list):
71
+ return []
72
+ out = []
73
+ for entry in raw:
74
+ if not isinstance(entry, dict):
75
+ continue
76
+ name = entry.get("name")
77
+ pid = entry.get("provider_id")
78
+ model = entry.get("model")
79
+ if not (name and pid and model):
80
+ continue # incomplete profile — skip silently
81
+ # context_limit is optional and best-effort: a missing/non-int value
82
+ # just means "not probed yet" (0), don't raise — the runtime falls back
83
+ # to ContextConfig.model_context_limit.
84
+ cl_raw = entry.get("context_limit", 0)
85
+ context_limit = cl_raw if isinstance(cl_raw, int) and not isinstance(cl_raw, bool) else 0
86
+ out.append(
87
+ Profile(
88
+ name=name,
89
+ provider_id=pid,
90
+ model=model,
91
+ base_url=entry.get("base_url", ""),
92
+ kind=entry.get("kind", "openai_compatible"),
93
+ context_limit=context_limit,
94
+ )
95
+ )
96
+ return out
97
+
98
+
99
+ def _resolve_active_profile(data: dict) -> str:
100
+ """Resolve the active profile name from config, with a sane default.
101
+
102
+ Returns the explicit `active_profile` value if set and non-empty. Otherwise
103
+ empty string — build_chat_model treats empty active_profile as "use the
104
+ legacy [model] path", so old users with no profiles are unaffected. (The
105
+ "default to first profile when active is unset" lives in build_chat_model,
106
+ not here, because the loader shouldn't mutate user intent on disk.)
107
+ """
108
+ active = data.get("active_profile", "")
109
+ if isinstance(active, str):
110
+ return active.strip()
111
+ return ""
112
+
113
+
114
+ def _read_fs_list(data: dict, key: str, default: list[str]) -> list[str]:
115
+ """Read a list-of-strings field from a config subtable (inline copy of
116
+ _from_dict._str_list, which is a closure-local helper and not visible here).
117
+
118
+ Returns the default on missing/malformed values — a bad config shouldn't
119
+ block startup.
120
+ """
121
+ v = data.get(key, default)
122
+ if isinstance(v, list) and all(isinstance(x, str) for x in v):
123
+ return v
124
+ return default
125
+
126
+
127
+ def _read_sandbox_fs(data, default: SandboxFsConfig | None) -> SandboxFsConfig | None:
128
+ """Read the [tools.sandbox_filesystem] subtable into a SandboxFsConfig.
129
+
130
+ Returns the `default` unchanged when the subtable is empty/missing, so
131
+ users who don't configure filesystem isolation get None (bubblewrap uses
132
+ its built-in workspace-only layout).
133
+ """
134
+ if not isinstance(data, dict) or not data:
135
+ return default
136
+ return SandboxFsConfig(
137
+ allow_write=_read_fs_list(data, "allow_write", default.allow_write if default else []),
138
+ deny_write=_read_fs_list(data, "deny_write", default.deny_write if default else []),
139
+ deny_read=_read_fs_list(data, "deny_read", default.deny_read if default else []),
140
+ allow_read=_read_fs_list(data, "allow_read", default.allow_read if default else []),
141
+ )
142
+
143
+
144
+ def _from_dict(data: dict) -> Config:
145
+ cfg = Config()
146
+ m = data.get("model", {})
147
+ t = data.get("tools", {})
148
+ s = data.get("skills", {})
149
+ se = data.get("session", {})
150
+ cl = data.get("cli", {})
151
+ cx = data.get("context", {})
152
+
153
+ # Validate int fields — TOML may give strings/bools if the user mis-types.
154
+ def _int(section: dict, key: str, default: int, section_name: str) -> int:
155
+ v = section.get(key, default)
156
+ if isinstance(v, bool) or not isinstance(v, int):
157
+ raise ValueError(f"config.toml [{section_name}] {key} 必须是整数,但得到 {type(v).__name__}: {v!r}")
158
+ return v
159
+
160
+ def _bool(section: dict, key: str, default: bool) -> bool:
161
+ v = section.get(key, default)
162
+ if not isinstance(v, bool):
163
+ # TOML's true/false parse to Python bool; anything else is a typo.
164
+ # Don't raise — fall back to default so a bad value doesn't block startup.
165
+ return default
166
+ return v
167
+
168
+ def _str_list(section: dict, key: str, default: list[str]) -> list[str]:
169
+ v = section.get(key, default)
170
+ if isinstance(v, list) and all(isinstance(x, str) for x in v):
171
+ return v
172
+ # Not a list of strings — fall back to default rather than crashing.
173
+ # A bad config value shouldn't block startup; the user will notice when
174
+ # their extra blocklist doesn't take effect.
175
+ return default
176
+
177
+ # Validate permission_mode against the known enum values.
178
+ perm = t.get("permission_mode", cfg.tools.permission_mode)
179
+ if isinstance(perm, str):
180
+ perm_lower = perm.lower()
181
+ valid = ("confirm", "plan", "auto", "full", "auto_edit")
182
+ if perm_lower not in valid:
183
+ raise ValueError(f"config.toml [tools] permission_mode='{perm}' 无效。可选值: {', '.join(valid)}")
184
+ perm = perm_lower
185
+
186
+ # context_limit is optional in [model]; a missing/non-int value falls back
187
+ # to 0 (not probed). Don't use the strict _int() helper — context_limit is
188
+ # a best-effort optimization, not a required config field.
189
+ m_cl_raw = m.get("context_limit", 0)
190
+ m_context_limit = m_cl_raw if isinstance(m_cl_raw, int) and not isinstance(m_cl_raw, bool) else 0
191
+ return Config(
192
+ model=ModelConfig(
193
+ default=m.get("default", cfg.model.default),
194
+ provider=m.get("provider", cfg.model.provider),
195
+ base_url=m.get("base_url", cfg.model.base_url),
196
+ provider_id=m.get("provider_id", ""),
197
+ max_output_tokens=_int(m, "max_output_tokens", cfg.model.max_output_tokens, "model"),
198
+ context_limit=m_context_limit,
199
+ ),
200
+ tools=ToolsConfig(
201
+ bash_shell=t.get("bash_shell", cfg.tools.bash_shell),
202
+ permission_mode=perm,
203
+ workspace_root=t.get("workspace_root", cfg.tools.workspace_root),
204
+ blocked_commands=_str_list(t, "blocked_commands", cfg.tools.blocked_commands),
205
+ network_allowed=_bool(t, "network_allowed", cfg.tools.network_allowed),
206
+ whitelist_mode=_bool(t, "whitelist_mode", cfg.tools.whitelist_mode),
207
+ allowed_commands=_str_list(t, "allowed_commands", cfg.tools.allowed_commands),
208
+ sandbox_mode=t.get("sandbox_mode", cfg.tools.sandbox_mode),
209
+ auto_allow_if_sandboxed=_bool(t, "auto_allow_if_sandboxed", cfg.tools.auto_allow_if_sandboxed),
210
+ sandbox_fs=_read_sandbox_fs(t.get("sandbox_filesystem", {}), cfg.tools.sandbox_fs),
211
+ ),
212
+ skills=SkillsConfig(
213
+ auto_load=s.get("auto_load", cfg.skills.auto_load),
214
+ harness=s.get("harness", cfg.skills.harness),
215
+ repo_url=s.get("repo_url", cfg.skills.repo_url),
216
+ ),
217
+ session=SessionConfig(
218
+ save_dir=se.get("save_dir", cfg.session.save_dir),
219
+ ),
220
+ cli=CliConfig(
221
+ theme=cl.get("theme", cfg.cli.theme),
222
+ show_tool_output=cl.get("show_tool_output", cfg.cli.show_tool_output),
223
+ ),
224
+ context=ContextConfig(
225
+ enabled=cx.get("enabled", cfg.context.enabled),
226
+ trigger_ratio=cx.get("trigger_ratio", cfg.context.trigger_ratio),
227
+ keep_recent=_int(cx, "keep_recent", cfg.context.keep_recent, "context"),
228
+ model_context_limit=_int(cx, "model_context_limit", cfg.context.model_context_limit, "context"),
229
+ ),
230
+ profiles=_parse_profiles(data),
231
+ active_profile=_resolve_active_profile(data),
232
+ )
233
+
234
+
235
+ def _apply_env(cfg: Config) -> Config:
236
+ model = cfg.model
237
+ v = os.environ.get("CODERIO_MODEL")
238
+ if v:
239
+ model = replace(model, default=v)
240
+ v = os.environ.get("CODERIO_PROVIDER")
241
+ if v:
242
+ model = replace(model, provider=v)
243
+ tools = cfg.tools
244
+ v = os.environ.get("CODERIO_BASH_SHELL")
245
+ if v:
246
+ tools = replace(tools, bash_shell=v)
247
+ # Preserve profiles/active_profile/context — env overrides only touch model/tools.
248
+ return Config(
249
+ model=model,
250
+ tools=tools,
251
+ skills=cfg.skills,
252
+ session=cfg.session,
253
+ cli=cfg.cli,
254
+ context=cfg.context,
255
+ profiles=cfg.profiles,
256
+ active_profile=cfg.active_profile,
257
+ )
258
+
259
+
260
+ def load_config(search_from: Path | str = ".", user_dir: Path | str | None = None) -> Config:
261
+ """Load config merging: defaults < user < project < env.
262
+
263
+ Layers (low->high): built-in defaults, user ~/.coderio, project ./.coderio, env vars.
264
+ """
265
+ search_from = Path(search_from)
266
+ if user_dir is None:
267
+ user_dir = _default_user_dir()
268
+ data = _merge(
269
+ _read_toml(Path(user_dir) / "config.toml"),
270
+ _read_toml(_find_project_dir(search_from) / ".coderio" / "config.toml"),
271
+ )
272
+ cfg = _from_dict(data)
273
+ return _apply_env(cfg)
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class ModelConfig:
8
+ default: str = "glm-4.5"
9
+ provider: str = "openai_compatible"
10
+ base_url: str = "https://open.bigmodel.cn/api/paas/v4"
11
+ provider_id: str = ""
12
+ max_output_tokens: int = 16384
13
+ # Context window size (tokens) for [model].default, probed at setup time.
14
+ # 0 = not probed (fall back to ContextConfig.model_context_limit). Mirrors
15
+ # Profile.context_limit for the legacy single-config path. Kept on ModelConfig
16
+ # (not ContextConfig) because it's per-model, not per-compaction-policy.
17
+ context_limit: int = 0
18
+
19
+
20
+ @dataclass
21
+ class Profile:
22
+ """A named, self-contained model configuration (provider + model + endpoint).
23
+
24
+ Bundles everything build_chat_model needs to construct a chat client, so the
25
+ user can keep several providers side by side (e.g. a Coding Plan subscription
26
+ and a personal OpenAI key) and switch between them with /profile without
27
+ re-running onboarding. The API key itself lives in the credentials file
28
+ (keyed by provider_id), not here — same as the legacy [model] path.
29
+ """
30
+
31
+ name: str
32
+ provider_id: str
33
+ model: str
34
+ base_url: str = ""
35
+ kind: str = "openai_compatible"
36
+ # Context window size (tokens) for THIS model, discovered at setup time by
37
+ # probing the provider's /v1/models/{id} endpoint. 0 = not probed (fall back
38
+ # to ContextConfig.model_context_limit). Stored per-profile because different
39
+ # providers/models have different windows — without this, a 256K model like
40
+ # step-3.7-flash gets mis-treated as the global default (200K), triggering
41
+ # compaction at 120K instead of 153K.
42
+ context_limit: int = 0
43
+
44
+
45
+ @dataclass
46
+ class ToolsConfig:
47
+ bash_shell: str = ""
48
+ permission_mode: str = "confirm"
49
+ # Workspace root for the shell backend's CWD. Empty = use the process CWD
50
+ # (the directory coderio was launched from). File-path isolation is handled
51
+ # by deepagents' backend virtual_mode (not by a coderio-side policy) — this
52
+ # value only affects where shell commands run.
53
+ workspace_root: str = ""
54
+ # Extra regex patterns appended to the built-in command blacklist. The
55
+ # built-in defaults (rm -rf /, mkfs, fork bomb, etc.) are always active;
56
+ # these are ADDITIONAL blocks for project-specific commands you never want
57
+ # run (e.g. ["git push --force", "npm publish"]). See command_policy.py.
58
+ blocked_commands: list[str] = field(default_factory=list)
59
+ # Master switch for web_fetch / web_search. False = offline mode (both web
60
+ # tools blocked). True (default) = web tools available subject to the
61
+ # normal permission gate.
62
+ network_allowed: bool = True
63
+ # Whitelist mode: when True, commands whose first token isn't in the
64
+ # allowed set (built-in + allowed_commands) are flagged for confirmation
65
+ # (NOT hard-blocked — FULL mode still allows them). Defaults to False
66
+ # (blacklist-only, backward compatible). See command_policy.py.
67
+ whitelist_mode: bool = False
68
+ # User-supplied additions to the built-in command whitelist. Applied only
69
+ # when whitelist_mode is True. E.g. ["docker", "kubectl"] to allow those
70
+ # without confirmation in whitelist mode.
71
+ allowed_commands: list[str] = field(default_factory=list)
72
+ # Sandbox mode: OS-level isolation for the execute tool.
73
+ # "off" = no OS sandbox (regex blacklist + whitelist only) [default]
74
+ # "job" = Job Object resource limits + reliable process-tree kill
75
+ # (prevents fork bombs, OOM, orphaned grandchildren)
76
+ # "write" = Job Object + Windows Restricted Token write isolation
77
+ # (reads system-wide, writes gated — needs Win10+, no admin)
78
+ # On Linux, "write" uses bubblewrap if available (see linux_sandbox.py).
79
+ sandbox_mode: str = "off"
80
+ # When True + sandbox_mode != "off", the execute (shell) tool auto-approves
81
+ # without a confirmation prompt (Claude Code's "autoAllowBashIfSandboxed"
82
+ # design: the sandbox provides the real isolation boundary, so per-command
83
+ # prompts become noise). Defaults False (backward compat). The blacklist
84
+ # still applies — rm -rf / is blocked even in auto-allow mode.
85
+ auto_allow_if_sandboxed: bool = False
86
+ # Filesystem isolation 4-tuple for the sandbox (Linux bubblewrap only;
87
+ # Windows ignores it — token is no-op). See SandboxFsConfig.
88
+ sandbox_fs: "SandboxFsConfig | None" = None
89
+
90
+
91
+ @dataclass
92
+ class SandboxFsConfig:
93
+ """Filesystem isolation config for the sandbox (Claude-Code-compatible 4-tuple).
94
+
95
+ Applied on Linux via bubblewrap mounts. Windows ignores these (the token
96
+ is currently a no-op — see win_sandbox.py docstring). All paths support:
97
+ - ``~/foo`` → home-relative
98
+ - ``./foo`` → workspace-relative
99
+ - ``foo`` → workspace-relative (same as ./)
100
+ - ``/abs`` → absolute
101
+
102
+ Semantics (bwrap mount order matters — later mounts override earlier):
103
+ - workspace is ALWAYS read-write (built-in, no need to list it)
104
+ - ``allow_write`` → extra read-write mounts (e.g. /tmp/build, ~/.cache)
105
+ - ``deny_write`` → read-only override (e.g. .git/hooks inside workspace)
106
+ - ``deny_read`` → tmpfs blackhole (path exists but contents invisible)
107
+ - ``allow_read`` → read-only re-mount punching through a deny_read
108
+ """
109
+
110
+ allow_write: list[str] = field(default_factory=list)
111
+ deny_write: list[str] = field(default_factory=list)
112
+ deny_read: list[str] = field(default_factory=list)
113
+ allow_read: list[str] = field(default_factory=list)
114
+
115
+
116
+ @dataclass
117
+ class SkillsConfig:
118
+ auto_load: bool = True
119
+ harness: bool = True
120
+ repo_url: str = "https://github.com/Lion-1209/Lion-Skills"
121
+
122
+
123
+ @dataclass
124
+ class CliConfig:
125
+ theme: str = "dark"
126
+ show_tool_output: bool = True
127
+
128
+
129
+ @dataclass
130
+ class SessionConfig:
131
+ save_dir: str = "~/.coderio/sessions"
132
+
133
+
134
+ @dataclass
135
+ class ContextConfig:
136
+ """Context-window compaction settings (spec: harness phase 2).
137
+
138
+ When the provider-reported input_tokens exceeds ``trigger_ratio`` of
139
+ ``model_context_limit``, old messages are summarized into a single system
140
+ message and the most recent ``keep_recent`` are kept verbatim. Disabled
141
+ when ``enabled=False`` (no compaction attempts, original behavior).
142
+ """
143
+
144
+ enabled: bool = True
145
+ trigger_ratio: float = 0.6 # compact at 60% of the context window (lowered
146
+ # from 0.75 — a 30-read_file analysis session
147
+ # hit 61k tokens without triggering; 60% gives
148
+ # earlier, healthier compaction)
149
+ keep_recent: int = 8 # messages preserved verbatim at the tail
150
+ model_context_limit: int = 200_000 # assumed window size when the active
151
+ # profile has no probed context_limit.
152
+ # Raised from 128K (too aggressive — a
153
+ # 256K model was compacting at 76K) to
154
+ # 200K, the floor for modern models
155
+ # (Claude, GPT-4o, step-3.7). The real
156
+ # value comes from probe_context_limit
157
+ # at setup time, stored per-profile.
158
+
159
+
160
+ @dataclass
161
+ class Config:
162
+ # All sub-config fields default to None and are populated in __post_init__.
163
+ # The `Type = None` default is a dataclass idiom: the field IS declared as
164
+ # the concrete type, but defaults to None before __post_init__ runs. mypy
165
+ # accepts this for dataclasses (the None is the "not yet initialized"
166
+ # sentinel, replaced in __post_init__ which the loader always calls).
167
+ model: ModelConfig = None # type: ignore[assignment]
168
+ tools: ToolsConfig = None # type: ignore[assignment]
169
+ skills: SkillsConfig = None # type: ignore[assignment]
170
+ session: SessionConfig = None # type: ignore[assignment]
171
+ cli: CliConfig = None # type: ignore[assignment]
172
+ context: ContextConfig = None # type: ignore[assignment]
173
+ # Named profiles (multi-config). Empty list = legacy single-config mode:
174
+ # build_chat_model falls through to the [model] section's 3-layer path,
175
+ # so existing users with no profiles are unaffected.
176
+ profiles: list = None # type: ignore[assignment]
177
+ active_profile: str = ""
178
+
179
+ def __post_init__(self):
180
+ if self.model is None:
181
+ object.__setattr__(self, "model", ModelConfig())
182
+ if self.tools is None:
183
+ object.__setattr__(self, "tools", ToolsConfig())
184
+ if self.skills is None:
185
+ object.__setattr__(self, "skills", SkillsConfig())
186
+ if self.session is None:
187
+ object.__setattr__(self, "session", SessionConfig())
188
+ if self.cli is None:
189
+ object.__setattr__(self, "cli", CliConfig())
190
+ if self.context is None:
191
+ object.__setattr__(self, "context", ContextConfig())
192
+ if self.profiles is None:
193
+ object.__setattr__(self, "profiles", [])
@@ -0,0 +1,136 @@
1
+ """First-use trust confirmation for repository-level config files.
2
+
3
+ SECURITY (2026-08-14 v2 audit, biggest remaining hole): the loader placed the
4
+ REPOSITORY's ``.coderio/config.toml`` ABOVE the user config — a cloned malicious
5
+ repo could set ``permission_mode = "full"``, point ``model.base_url`` at an
6
+ attacker's endpoint (session exfiltration), and ``.mcp.json``'s ``command``
7
+ entries are spawned directly at startup. Cloning a hostile repo + starting
8
+ coderio ≈ arbitrary code execution, with zero prompt.
9
+
10
+ Mitigation (same approach as Claude Code / Codex / ZCode): on first detection
11
+ of repo-level config, SHOW the user what it contains and require explicit
12
+ confirmation. The confirmation is recorded per-repo in the USER's trust store
13
+ (``~/.coderio/trusted-repos.json``), keyed by a content hash — a later commit
14
+ that changes the config re-triggers the prompt (trust the content, not just
15
+ the path).
16
+
17
+ Non-interactive contexts (tests, CI) use :func:`is_repo_trusted` /
18
+ :func:`mark_repo_trusted` directly, or pass ``assume_trusted=True``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ from pathlib import Path
27
+
28
+ _log = logging.getLogger(__name__)
29
+
30
+ # Files that constitute "repo-level config" and therefore require trust.
31
+ # A repo with none of these starts silently (nothing to confirm).
32
+ REPO_CONFIG_FILES = (".coderio/config.toml", ".mcp.json")
33
+
34
+ # Safety-relevant keys called out in the confirmation summary — the user should
35
+ # see these even if they don't read the full file.
36
+ _SENSITIVE_KEYS = ("permission_mode", "sandbox_mode", "base_url", "auto_allow_if_sandboxed", "network_allowed")
37
+
38
+
39
+ def _trust_store(user_dir: Path | str) -> Path:
40
+ return Path(user_dir) / "trusted-repos.json"
41
+
42
+
43
+ def _repo_fingerprint(project_dir: Path) -> str:
44
+ """Hash the CONTENT of every repo config file (not the path alone).
45
+
46
+ Content-keyed trust: editing the config after confirmation re-triggers the
47
+ prompt — a malicious upstream commit can't ride on a previously-granted
48
+ trust.
49
+ """
50
+ h = hashlib.sha256()
51
+ for rel in REPO_CONFIG_FILES:
52
+ p = project_dir / rel
53
+ h.update(rel.encode("utf-8"))
54
+ if p.is_file():
55
+ h.update(p.read_bytes())
56
+ return h.hexdigest()
57
+
58
+
59
+ def existing_repo_configs(project_dir: Path | str) -> list[Path]:
60
+ """Return the repo-level config files that exist (possibly empty list)."""
61
+ project_dir = Path(project_dir)
62
+ return [project_dir / rel for rel in REPO_CONFIG_FILES if (project_dir / rel).is_file()]
63
+
64
+
65
+ def is_repo_trusted(project_dir: Path | str, user_dir: Path | str) -> bool:
66
+ """True when the repo's CURRENT config content matches a stored trust entry.
67
+
68
+ A repo with no repo-level config is trusted by definition (nothing to
69
+ confirm). A config change after confirmation invalidates the trust.
70
+ """
71
+ project_dir = Path(project_dir)
72
+ if not existing_repo_configs(project_dir):
73
+ return True
74
+ store = _trust_store(user_dir)
75
+ if not store.is_file():
76
+ return False
77
+ try:
78
+ entries = json.loads(store.read_text(encoding="utf-8"))
79
+ except (json.JSONDecodeError, OSError):
80
+ return False
81
+ trusted = entries.get(str(project_dir.resolve()))
82
+ return trusted == _repo_fingerprint(project_dir)
83
+
84
+
85
+ def mark_repo_trusted(project_dir: Path | str, user_dir: Path | str) -> None:
86
+ """Record the repo's current config content as trusted (after the user
87
+ confirms). Best-effort: a failure to write the store logs a warning and
88
+ leaves the repo untrusted (safe direction — the prompt reappears)."""
89
+ project_dir = Path(project_dir)
90
+ store = _trust_store(user_dir)
91
+ try:
92
+ entries: dict = {}
93
+ if store.is_file():
94
+ entries = json.loads(store.read_text(encoding="utf-8"))
95
+ if not isinstance(entries, dict):
96
+ entries = {}
97
+ entries[str(project_dir.resolve())] = _repo_fingerprint(project_dir)
98
+ store.parent.mkdir(parents=True, exist_ok=True)
99
+ store.write_text(json.dumps(entries, indent=2), encoding="utf-8")
100
+ except (json.JSONDecodeError, OSError) as e:
101
+ _log.warning("could not persist repo trust for %s: %s", project_dir, e)
102
+
103
+
104
+ def summarize_repo_configs(project_dir: Path | str) -> str:
105
+ """Build the human-readable summary shown in the confirmation prompt.
106
+
107
+ Lists each file, its size, and any safety-relevant settings found in it —
108
+ so ``permission_mode = "full"`` can't hide inside a long TOML file the user
109
+ didn't read. MCP ``command``/``url`` entries are listed by server name
110
+ (those spawn processes / make network calls at startup).
111
+ """
112
+ project_dir = Path(project_dir)
113
+ lines = []
114
+ for path in existing_repo_configs(project_dir):
115
+ rel = path.relative_to(project_dir).as_posix()
116
+ lines.append(f"{rel} ({path.stat().st_size} bytes)")
117
+ try:
118
+ text = path.read_text(encoding="utf-8")
119
+ except OSError:
120
+ lines.append(" <unreadable>")
121
+ continue
122
+ if path.name == "config.toml":
123
+ for ln in text.splitlines():
124
+ s = ln.strip()
125
+ if any(k in s for k in _SENSITIVE_KEYS) and "=" in s:
126
+ lines.append(f" {s}")
127
+ else: # .mcp.json — list servers (they spawn/connect at startup)
128
+ try:
129
+ servers = json.loads(text).get("mcpServers", {})
130
+ for name in servers:
131
+ cfg = servers[name] or {}
132
+ what = cfg.get("command") or cfg.get("url") or "?"
133
+ lines.append(f" server {name!r}: {what}")
134
+ except json.JSONDecodeError:
135
+ lines.append(" <invalid JSON>")
136
+ return "\n".join(lines)
@@ -0,0 +1,3 @@
1
+ from coderio.llm.factory import build_chat_model
2
+
3
+ __all__ = ["build_chat_model"]