pantheon-opencode 1.4.3 → 1.5.0-beta.10
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.
- package/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/manifest.json +21 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/CHANGELOG.md +1026 -0
- package/README.md +292 -1080
- package/README.pt-BR.md +308 -0
- package/bin/pantheon-init.mjs +207 -31
- package/opencode.json +1 -0
- package/package.json +34 -14
- package/plugin.json +15 -0
- package/pyproject.toml +65 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +451 -44
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +306 -129
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +23 -10
- package/scripts/install/config-migration.mjs +399 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +396 -121
- package/scripts/install/plugin.mjs +89 -49
- package/scripts/install/shared.mjs +55 -159
- package/scripts/install/strings.mjs +129 -0
- package/scripts/install-mcp.mjs +45 -232
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +798 -60
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +313 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/release-beta-version.mjs +56 -60
- package/scripts/sync-artifacts.mjs +169 -0
- package/scripts/sync-tui.mjs +135 -42
- package/scripts/test-opencode-v1-v2-sandbox.sh +862 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +230 -86
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/mnemosyne.md +1 -1
- package/src/agents/zeus.md +27 -16
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +451 -44
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +798 -60
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/auto-wake.ts +1 -5
- package/src/pantheon/background-job-board.ts +110 -2
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +356 -69
- package/src/pantheon/delegate-manager.ts +766 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +406 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1337 -0
- package/src/pantheon/file-persistence-salvage.ts +64 -0
- package/src/pantheon/file-persistence.ts +194 -45
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/shared-board.ts +37 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +689 -0
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +148 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +261 -51
- package/src/plugins/pantheon-hooks.ts +51 -0
- package/src/plugins/tui/dist/tui.d.ts +116 -24
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +416 -149
- package/src/plugins/tui/dist/tui.tsx +456 -87
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +462 -95
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -34,11 +34,11 @@ def pantheon_home() -> Path:
|
|
|
34
34
|
"""
|
|
35
35
|
env = os.environ.get("PANTHEON_HOME")
|
|
36
36
|
if env:
|
|
37
|
-
return Path(env).expanduser().
|
|
37
|
+
return Path(env).expanduser().absolute()
|
|
38
38
|
|
|
39
39
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
40
40
|
if xdg:
|
|
41
|
-
return Path(xdg).expanduser().
|
|
41
|
+
return Path(xdg).expanduser().absolute() / "opencode"
|
|
42
42
|
|
|
43
43
|
return Path.home() / ".config" / "opencode"
|
|
44
44
|
|
|
@@ -48,7 +48,8 @@ def pantheon_project() -> Path | None:
|
|
|
48
48
|
|
|
49
49
|
Resolution priority:
|
|
50
50
|
1. $PANTHEON_PROJECT env var (explicit override)
|
|
51
|
-
2.
|
|
51
|
+
2. The validated logical ``$PWD`` supplied by the launcher
|
|
52
|
+
3. Current working directory (set by MCP client's cwd in opencode.json)
|
|
52
53
|
|
|
53
54
|
Returns:
|
|
54
55
|
Absolute Path to the project root, or None if neither is available
|
|
@@ -56,10 +57,50 @@ def pantheon_project() -> Path | None:
|
|
|
56
57
|
"""
|
|
57
58
|
env = os.environ.get("PANTHEON_PROJECT")
|
|
58
59
|
if env:
|
|
59
|
-
return Path(env).expanduser().
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
return Path(env).expanduser().absolute()
|
|
61
|
+
|
|
62
|
+
logical_cwd = os.environ.get("PWD")
|
|
63
|
+
try:
|
|
64
|
+
cwd = os.getcwd()
|
|
65
|
+
except OSError:
|
|
66
|
+
cwd = ""
|
|
67
|
+
if logical_cwd and _is_valid_logical_cwd(logical_cwd, cwd):
|
|
68
|
+
return Path(logical_cwd).expanduser().absolute()
|
|
62
69
|
if cwd:
|
|
63
|
-
return Path(cwd).
|
|
70
|
+
return Path(cwd).absolute()
|
|
64
71
|
|
|
65
72
|
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_valid_logical_cwd(logical_cwd: str, physical_cwd: str) -> bool:
|
|
76
|
+
"""Validate a launcher-provided logical cwd without resolving its result.
|
|
77
|
+
|
|
78
|
+
``PWD`` is trusted only when it is an existing directory naming the same
|
|
79
|
+
inode as the process cwd. This preserves symlink spelling while rejecting
|
|
80
|
+
forged or stale environment values.
|
|
81
|
+
"""
|
|
82
|
+
logical = Path(logical_cwd).expanduser()
|
|
83
|
+
if not logical.is_absolute() or not logical.is_dir():
|
|
84
|
+
return False
|
|
85
|
+
try:
|
|
86
|
+
return os.path.samefile(logical, physical_cwd)
|
|
87
|
+
except (OSError, ValueError):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_symlink_component(path: Path) -> bool:
|
|
92
|
+
"""Return whether any existing lexical component of ``path`` is a symlink.
|
|
93
|
+
|
|
94
|
+
The lexical path is intentionally inspected before ``resolve()`` so a
|
|
95
|
+
symlink cannot be hidden by resolving to an otherwise trusted directory.
|
|
96
|
+
Missing trailing components are allowed; this keeps normal project paths
|
|
97
|
+
usable while still checking every existing ancestor.
|
|
98
|
+
"""
|
|
99
|
+
current = Path(path.anchor) if path.is_absolute() else Path.cwd()
|
|
100
|
+
for component in path.parts[1:] if path.is_absolute() else path.parts:
|
|
101
|
+
if component in {"", "."}:
|
|
102
|
+
continue
|
|
103
|
+
current /= component
|
|
104
|
+
if current.is_symlink():
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
@@ -16,10 +16,12 @@ Or via MCP client (stdio transport):
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import asyncio
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
19
21
|
import logging
|
|
20
22
|
import os
|
|
21
|
-
import signal
|
|
22
23
|
import shutil
|
|
24
|
+
import signal
|
|
23
25
|
import stat
|
|
24
26
|
import sys
|
|
25
27
|
import time
|
|
@@ -28,7 +30,6 @@ from pathlib import Path
|
|
|
28
30
|
from typing import Any
|
|
29
31
|
|
|
30
32
|
import yaml
|
|
31
|
-
|
|
32
33
|
from _pantheon_paths import pantheon_home, pantheon_project
|
|
33
34
|
from mcp.server.fastmcp import FastMCP
|
|
34
35
|
|
|
@@ -39,21 +40,47 @@ SCRIPT_TIMEOUT: int = 30
|
|
|
39
40
|
# executor for longer than 5 minutes.
|
|
40
41
|
MAX_SCRIPT_TIMEOUT: int = 300
|
|
41
42
|
|
|
43
|
+
# ── Common status contract ────────────────────────────────────────────────────
|
|
44
|
+
# Mirrors the nine-code `NativeTaskStatus` contract introduced in B3-05. Every
|
|
45
|
+
# code-mode result is labelled with one of these statuses so callers can apply
|
|
46
|
+
# the same retry/skip/escalate strategy across the whole platform.
|
|
47
|
+
CONTRACT_STATUSES: frozenset[str] = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"OK",
|
|
50
|
+
"UNSUPPORTED",
|
|
51
|
+
"UNAVAILABLE",
|
|
52
|
+
"INVALID_INPUT",
|
|
53
|
+
"INVALID_STATE",
|
|
54
|
+
"CONFLICT",
|
|
55
|
+
"CORRUPT_DATA",
|
|
56
|
+
"TIMEOUT",
|
|
57
|
+
"ESCALATE",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Explicit installation mode: a script only runs when it is listed in this
|
|
62
|
+
# manifest with a matching SHA-256. No manifest → nothing executes.
|
|
63
|
+
MANIFEST_FILENAME: str = "manifest.json"
|
|
64
|
+
MANIFEST_VERSION: int = 1
|
|
65
|
+
_SHA256_HEX_LEN: int = 64
|
|
66
|
+
|
|
42
67
|
# ── Env Allowlist ──────────────────────────────────────────────────────────────
|
|
43
68
|
# Only these variables are passed to script subprocesses. Everything else
|
|
44
69
|
# (secrets, cloud credentials, etc.) is stripped to limit blast radius.
|
|
45
|
-
_CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
_CODE_MODE_ENV_ALLOWLIST: frozenset[str] = frozenset(
|
|
71
|
+
{
|
|
72
|
+
"PATH",
|
|
73
|
+
"HOME",
|
|
74
|
+
"LANG",
|
|
75
|
+
"PANTHEON_HOME",
|
|
76
|
+
"PANTHEON_PROJECT",
|
|
77
|
+
"OPENAI_API_KEY",
|
|
78
|
+
"OPENAI_BASE_URL",
|
|
79
|
+
"EVAL_JUDGE_MODEL",
|
|
80
|
+
"SHELL",
|
|
81
|
+
"USER",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
57
84
|
|
|
58
85
|
# Optional extra vars that callers may opt-in to pass through. Populate
|
|
59
86
|
# this set at startup if needed (e.g. ``CODE_MODE_EXTRA_ENV.add("MY_VAR")``).
|
|
@@ -128,24 +155,109 @@ def _detect_prlimit() -> str | None:
|
|
|
128
155
|
return None
|
|
129
156
|
return shutil.which("prlimit")
|
|
130
157
|
|
|
158
|
+
|
|
131
159
|
# ── Scripts Directory Resolution ─────────────────────────────────────────────
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
|
|
160
|
+
# Project directories are selected before global directories. Once a usable
|
|
161
|
+
# project directory is selected, manifest errors are deliberately fail-closed;
|
|
162
|
+
# execution must never silently cross the project/global trust boundary.
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_within(path: Path, root: Path) -> bool:
|
|
166
|
+
"""Return whether a resolved *path* is contained by a resolved *root*."""
|
|
167
|
+
try:
|
|
168
|
+
return os.path.commonpath((str(root), str(path))) == str(root)
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _has_usable_scripts(scripts_dir: Path) -> bool:
|
|
174
|
+
"""Return whether *scripts_dir* contains an executable code-mode target.
|
|
175
|
+
|
|
176
|
+
A directory is only a valid resolver candidate when it contains a regular
|
|
177
|
+
``.py`` or ``.sh`` file that can pass the same direct-child and path
|
|
178
|
+
containment rules used by script execution. This prevents an empty
|
|
179
|
+
project overlay (or a directory containing only metadata) from masking a
|
|
180
|
+
lower-priority installation.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
if scripts_dir.is_symlink():
|
|
184
|
+
return False
|
|
185
|
+
resolved_dir = scripts_dir.resolve(strict=True)
|
|
186
|
+
if not resolved_dir.is_dir():
|
|
187
|
+
return False
|
|
188
|
+
for script_path in scripts_dir.iterdir():
|
|
189
|
+
if script_path.name.startswith("."):
|
|
190
|
+
continue
|
|
191
|
+
if script_path.suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
192
|
+
continue
|
|
193
|
+
try:
|
|
194
|
+
resolved_script = script_path.resolve(strict=True)
|
|
195
|
+
except (OSError, RuntimeError):
|
|
196
|
+
continue
|
|
197
|
+
if (
|
|
198
|
+
resolved_script.is_file()
|
|
199
|
+
and resolved_script.parent == resolved_dir
|
|
200
|
+
and _is_within(resolved_script, resolved_dir)
|
|
201
|
+
):
|
|
202
|
+
return True
|
|
203
|
+
except (OSError, RuntimeError):
|
|
204
|
+
return False
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _packaged_scripts_dir() -> Path | None:
|
|
209
|
+
"""Return the code-mode dir shipped inside the installed package, if any.
|
|
210
|
+
|
|
211
|
+
Walks up from this file looking for a `.pantheon/code-mode/` directory
|
|
212
|
+
(e.g. `<pkg>/.pantheon/code-mode` when installed from the tarball).
|
|
213
|
+
Returns None when no packaged copy exists.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
here = Path(__file__).resolve(strict=True)
|
|
217
|
+
except (OSError, RuntimeError):
|
|
218
|
+
return None
|
|
219
|
+
for parent in [here.parent, *here.parents]:
|
|
220
|
+
try:
|
|
221
|
+
candidate = parent / ".pantheon" / "code-mode"
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
if _has_usable_scripts(candidate):
|
|
225
|
+
return candidate
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _resolve_scripts_dir(candidates: list[Path], *, allow_packaged: bool = True) -> Path | None:
|
|
230
|
+
"""Select the first usable candidate, optionally trying the package copy."""
|
|
231
|
+
for candidate in candidates:
|
|
232
|
+
if _has_usable_scripts(candidate):
|
|
233
|
+
return candidate
|
|
234
|
+
return _packaged_scripts_dir() if allow_packaged else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _resolve_code_mode_dir() -> Path:
|
|
238
|
+
"""Resolve code-mode scripts project-first, with a global-only fallback.
|
|
239
|
+
|
|
240
|
+
A project directory is a trust boundary: if one is selected, callers use
|
|
241
|
+
its manifest even when that manifest is missing or corrupt. Global users
|
|
242
|
+
retain the packaged/global fallback when no usable project directory is
|
|
243
|
+
available.
|
|
244
|
+
"""
|
|
245
|
+
project = pantheon_project()
|
|
246
|
+
if project is not None:
|
|
247
|
+
project_candidates = [
|
|
248
|
+
project / ".opencode" / ".pantheon" / "code-mode",
|
|
249
|
+
project / ".pantheon" / "code-mode",
|
|
250
|
+
]
|
|
251
|
+
selected = _resolve_scripts_dir(project_candidates, allow_packaged=False)
|
|
252
|
+
if selected is not None:
|
|
253
|
+
return selected
|
|
254
|
+
|
|
255
|
+
global_dir = pantheon_home() / ".pantheon" / "code-mode"
|
|
256
|
+
return _resolve_scripts_dir([global_dir]) or global_dir
|
|
257
|
+
|
|
258
|
+
|
|
136
259
|
_PANTHEON_HOME: Path = pantheon_home()
|
|
137
|
-
|
|
138
|
-
_proj = pantheon_project()
|
|
139
|
-
if _proj is not None:
|
|
140
|
-
_SCRIPTS_DIR_CANDIDATES.append(_proj / ".opencode" / ".pantheon" / "code-mode")
|
|
141
|
-
_SCRIPTS_DIR_CANDIDATES.append(_proj / ".pantheon" / "code-mode")
|
|
142
|
-
_SCRIPTS_DIR_CANDIDATES.append(_PANTHEON_HOME / ".pantheon" / "code-mode")
|
|
143
|
-
|
|
144
|
-
SCRIPTS_DIR: Path = _PANTHEON_HOME / ".pantheon" / "code-mode" # default
|
|
145
|
-
for _candidate in _SCRIPTS_DIR_CANDIDATES:
|
|
146
|
-
if _candidate.is_dir():
|
|
147
|
-
SCRIPTS_DIR = _candidate
|
|
148
|
-
break
|
|
260
|
+
SCRIPTS_DIR: Path = _resolve_code_mode_dir()
|
|
149
261
|
|
|
150
262
|
# ── FastMCP App ───────────────────────────────────────────────────────────────
|
|
151
263
|
mcp = FastMCP(
|
|
@@ -157,12 +269,19 @@ mcp = FastMCP(
|
|
|
157
269
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
158
270
|
|
|
159
271
|
|
|
160
|
-
def _validate_script_name(script_name: str) -> Path:
|
|
272
|
+
def _validate_script_name(script_name: str, scripts_dir: Path | None = None) -> Path:
|
|
161
273
|
"""Validate a script name and return its resolved path.
|
|
162
274
|
|
|
163
|
-
|
|
164
|
-
|
|
275
|
+
Args:
|
|
276
|
+
script_name: Bare file name of a script in the code-mode directory.
|
|
277
|
+
scripts_dir: Directory to resolve against. Defaults to ``SCRIPTS_DIR``.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If the name is invalid, traverses paths, or has a
|
|
281
|
+
disallowed extension.
|
|
165
282
|
"""
|
|
283
|
+
scripts_dir = scripts_dir or SCRIPTS_DIR
|
|
284
|
+
|
|
166
285
|
if not script_name:
|
|
167
286
|
raise ValueError("Script name cannot be empty")
|
|
168
287
|
|
|
@@ -177,15 +296,238 @@ def _validate_script_name(script_name: str) -> Path:
|
|
|
177
296
|
allowed = ", ".join(sorted(ALLOWED_EXTENSIONS))
|
|
178
297
|
raise ValueError(f"Extension '{ext}' not allowed. Allowed: {allowed}")
|
|
179
298
|
|
|
180
|
-
|
|
181
|
-
|
|
299
|
+
if scripts_dir.is_symlink():
|
|
300
|
+
raise ValueError(f"Invalid script name: '{script_name}'")
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
scripts_root = scripts_dir.resolve(strict=True)
|
|
304
|
+
script_path = (scripts_dir / name).resolve(strict=True)
|
|
305
|
+
except (FileNotFoundError, OSError, RuntimeError):
|
|
306
|
+
raise ValueError(f"Script '{script_name}' not found") from None
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
not _is_within(script_path, scripts_root)
|
|
310
|
+
or script_path.parent != scripts_root
|
|
311
|
+
or not script_path.is_file()
|
|
312
|
+
):
|
|
182
313
|
raise ValueError(f"Invalid script name: '{script_name}'")
|
|
183
|
-
if not script_path.exists():
|
|
184
|
-
raise ValueError(f"Script '{script_name}' not found")
|
|
185
314
|
|
|
186
315
|
return script_path
|
|
187
316
|
|
|
188
317
|
|
|
318
|
+
# ── Installation Mode: Manifest + SHA-256 ─────────────────────────────────────
|
|
319
|
+
# The manifest is the explicit opt-in. A script is executable only when its
|
|
320
|
+
# bare name is present in `manifest.json` AND the SHA-256 of the on-disk file
|
|
321
|
+
# matches the recorded digest. Any deviation fails closed with a contract
|
|
322
|
+
# status instead of executing.
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class ManifestError(Exception):
|
|
326
|
+
"""Raised when the code-mode manifest cannot authorise execution."""
|
|
327
|
+
|
|
328
|
+
def __init__(self, status: str, message: str) -> None:
|
|
329
|
+
super().__init__(message)
|
|
330
|
+
self.status = status
|
|
331
|
+
self.message = message
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _manifest_path(scripts_dir: Path | None = None) -> Path:
|
|
335
|
+
"""Return the manifest path for *scripts_dir* (defaults to SCRIPTS_DIR)."""
|
|
336
|
+
return (scripts_dir or SCRIPTS_DIR) / MANIFEST_FILENAME
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _sha256_file(path: Path) -> str:
|
|
340
|
+
"""Return the hex SHA-256 digest of *path* (streamed, binary-safe)."""
|
|
341
|
+
digest = hashlib.sha256()
|
|
342
|
+
with path.open("rb") as handle:
|
|
343
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
344
|
+
digest.update(chunk)
|
|
345
|
+
return digest.hexdigest()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
|
|
349
|
+
"""Load and validate the manifest, returning ``{script_name: sha256}``.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
ManifestError: With ``INVALID_STATE`` when the manifest is absent (no
|
|
353
|
+
explicit opt-in), or ``CORRUPT_DATA`` when it is unreadable or
|
|
354
|
+
malformed.
|
|
355
|
+
"""
|
|
356
|
+
manifest_file = _manifest_path(scripts_dir)
|
|
357
|
+
if not manifest_file.is_file():
|
|
358
|
+
raise ManifestError(
|
|
359
|
+
"INVALID_STATE",
|
|
360
|
+
f"Code-mode manifest not found at {manifest_file}. No script executes "
|
|
361
|
+
"without explicit opt-in — run approve_code_script first.",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
raw = manifest_file.read_text(encoding="utf-8")
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
raise ManifestError(
|
|
368
|
+
"CORRUPT_DATA", f"Code-mode manifest unreadable: {exc}"
|
|
369
|
+
) from None
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
data = json.loads(raw)
|
|
373
|
+
except (json.JSONDecodeError, ValueError):
|
|
374
|
+
raise ManifestError(
|
|
375
|
+
"CORRUPT_DATA",
|
|
376
|
+
f"Code-mode manifest is malformed JSON: {manifest_file}",
|
|
377
|
+
) from None
|
|
378
|
+
|
|
379
|
+
if not isinstance(data, dict):
|
|
380
|
+
raise ManifestError(
|
|
381
|
+
"CORRUPT_DATA", "Code-mode manifest must be a JSON object."
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if data.get("version") != 1:
|
|
385
|
+
raise ManifestError(
|
|
386
|
+
"CORRUPT_DATA", "Code-mode manifest malformed: version must be 1."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
scripts = data.get("scripts")
|
|
390
|
+
if not isinstance(scripts, dict):
|
|
391
|
+
raise ManifestError(
|
|
392
|
+
"CORRUPT_DATA",
|
|
393
|
+
"Code-mode manifest is malformed: missing 'scripts' object.",
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
approved: dict[str, str] = {}
|
|
397
|
+
for name, digest in scripts.items():
|
|
398
|
+
if (
|
|
399
|
+
not isinstance(name, str)
|
|
400
|
+
or not isinstance(digest, str)
|
|
401
|
+
or len(digest) != _SHA256_HEX_LEN
|
|
402
|
+
or any(char not in "0123456789abcdefABCDEF" for char in digest)
|
|
403
|
+
):
|
|
404
|
+
raise ManifestError(
|
|
405
|
+
"CORRUPT_DATA",
|
|
406
|
+
f"Code-mode manifest entry for '{name}' is malformed "
|
|
407
|
+
"(expected a 64-character SHA-256 hex digest).",
|
|
408
|
+
)
|
|
409
|
+
approved[name] = digest.lower()
|
|
410
|
+
return approved
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _verify_approved(script_path: Path, scripts_dir: Path | None = None) -> None:
|
|
414
|
+
"""Authorise *script_path* against the manifest.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ManifestError: ``CONFLICT`` when the script is not listed and
|
|
418
|
+
``CORRUPT_DATA`` when its hash does not match the recorded digest.
|
|
419
|
+
"""
|
|
420
|
+
approved = _load_manifest(scripts_dir)
|
|
421
|
+
name = script_path.name
|
|
422
|
+
if name not in approved:
|
|
423
|
+
raise ManifestError(
|
|
424
|
+
"CONFLICT",
|
|
425
|
+
f"Script '{name}' is not approved in the code-mode manifest. "
|
|
426
|
+
"Run approve_code_script to opt in.",
|
|
427
|
+
)
|
|
428
|
+
actual = _sha256_file(script_path)
|
|
429
|
+
if actual != approved[name]:
|
|
430
|
+
raise ManifestError(
|
|
431
|
+
"CORRUPT_DATA",
|
|
432
|
+
f"Script '{name}' hash mismatch (expected {approved[name]}, got "
|
|
433
|
+
f"{actual}). Re-approve the script after verifying the change.",
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _approve_script(
|
|
438
|
+
script_name: str, scripts_dir: Path | None = None
|
|
439
|
+
) -> tuple[str, str]:
|
|
440
|
+
"""Add or refresh *script_name* in the manifest with its SHA-256.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
A ``(status, message)`` tuple; the status is a contract code and is
|
|
444
|
+
``OK`` on success.
|
|
445
|
+
"""
|
|
446
|
+
target_dir = scripts_dir or SCRIPTS_DIR
|
|
447
|
+
try:
|
|
448
|
+
script_path = _validate_script_name(script_name, target_dir)
|
|
449
|
+
except ValueError as exc:
|
|
450
|
+
return "INVALID_INPUT", str(exc)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
approved = _load_manifest(target_dir)
|
|
454
|
+
except ManifestError as exc:
|
|
455
|
+
if exc.status == "INVALID_STATE":
|
|
456
|
+
approved = {}
|
|
457
|
+
else:
|
|
458
|
+
return exc.status, exc.message
|
|
459
|
+
|
|
460
|
+
approved[script_path.name] = _sha256_file(script_path)
|
|
461
|
+
payload = {
|
|
462
|
+
"version": MANIFEST_VERSION,
|
|
463
|
+
"scripts": dict(sorted(approved.items())),
|
|
464
|
+
}
|
|
465
|
+
try:
|
|
466
|
+
_manifest_path(target_dir).write_text(
|
|
467
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
468
|
+
)
|
|
469
|
+
except OSError as exc:
|
|
470
|
+
return "UNAVAILABLE", f"Failed to write code-mode manifest: {exc}"
|
|
471
|
+
|
|
472
|
+
digest = approved[script_path.name]
|
|
473
|
+
return "OK", f"Approved '{script_path.name}' with SHA-256 {digest}"
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _generate_manifest(scripts_dir: Path | None = None) -> int:
|
|
477
|
+
"""Write a manifest covering every valid script in *scripts_dir*.
|
|
478
|
+
|
|
479
|
+
Used by the installer to (re)seed the explicit opt-in for bundled scripts.
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
The number of scripts recorded.
|
|
483
|
+
"""
|
|
484
|
+
target_dir = scripts_dir or SCRIPTS_DIR
|
|
485
|
+
try:
|
|
486
|
+
resolved_dir = target_dir.resolve(strict=True)
|
|
487
|
+
except (OSError, RuntimeError):
|
|
488
|
+
return 0
|
|
489
|
+
|
|
490
|
+
approved: dict[str, str] = {}
|
|
491
|
+
if resolved_dir.is_dir() and not resolved_dir.is_symlink():
|
|
492
|
+
for candidate in sorted(resolved_dir.iterdir()):
|
|
493
|
+
if candidate.name.startswith("."):
|
|
494
|
+
continue
|
|
495
|
+
if candidate.suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
496
|
+
continue
|
|
497
|
+
try:
|
|
498
|
+
resolved = candidate.resolve(strict=True)
|
|
499
|
+
except (OSError, RuntimeError):
|
|
500
|
+
continue
|
|
501
|
+
if (
|
|
502
|
+
resolved.is_file()
|
|
503
|
+
and resolved.parent == resolved_dir
|
|
504
|
+
and _is_within(resolved, resolved_dir)
|
|
505
|
+
):
|
|
506
|
+
approved[candidate.name] = _sha256_file(resolved)
|
|
507
|
+
|
|
508
|
+
payload = {
|
|
509
|
+
"version": MANIFEST_VERSION,
|
|
510
|
+
"scripts": dict(sorted(approved.items())),
|
|
511
|
+
}
|
|
512
|
+
try:
|
|
513
|
+
_manifest_path(target_dir).write_text(
|
|
514
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
515
|
+
)
|
|
516
|
+
except OSError:
|
|
517
|
+
return 0
|
|
518
|
+
return len(approved)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _contract_result(
|
|
522
|
+
status: str, message: str, json_output: bool
|
|
523
|
+
) -> str | dict[str, Any]:
|
|
524
|
+
"""Build a contract-labelled error result (text or structured JSON)."""
|
|
525
|
+
if json_output:
|
|
526
|
+
return {"status": status, "error": message}
|
|
527
|
+
return f"[{status}] {message}"
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
|
|
189
531
|
def _format_output(
|
|
190
532
|
stdout: str,
|
|
191
533
|
stderr: str,
|
|
@@ -255,7 +597,9 @@ def _parse_frontmatter(script_path: Path) -> dict[str, Any]:
|
|
|
255
597
|
break
|
|
256
598
|
if not stripped.startswith("#"):
|
|
257
599
|
return {} # non-comment line inside the block → not frontmatter
|
|
258
|
-
body_lines.append(
|
|
600
|
+
body_lines.append(
|
|
601
|
+
stripped[1:].lstrip() if stripped.startswith("# ") else stripped[1:]
|
|
602
|
+
)
|
|
259
603
|
else:
|
|
260
604
|
return {} # no closing delimiter
|
|
261
605
|
|
|
@@ -307,10 +651,12 @@ def _build_result(
|
|
|
307
651
|
metadata: dict[str, Any],
|
|
308
652
|
json_output: bool,
|
|
309
653
|
timeout_s: int,
|
|
654
|
+
status: str = "OK",
|
|
310
655
|
) -> str | dict[str, Any]:
|
|
311
656
|
"""Build the tool result in plain-text or structured JSON form."""
|
|
312
657
|
if json_output:
|
|
313
658
|
return {
|
|
659
|
+
"status": status,
|
|
314
660
|
"stdout": stdout,
|
|
315
661
|
"stderr": stderr,
|
|
316
662
|
"exit_code": exit_code,
|
|
@@ -347,7 +693,9 @@ async def list_code_mode_scripts() -> str:
|
|
|
347
693
|
|
|
348
694
|
@mcp.resource(
|
|
349
695
|
"pantheon://code-mode/scripts/{script_name}",
|
|
350
|
-
description=
|
|
696
|
+
description=(
|
|
697
|
+
"Content of a code-mode script by name, with parsed frontmatter metadata"
|
|
698
|
+
),
|
|
351
699
|
)
|
|
352
700
|
async def get_code_mode_script(script_name: str) -> str:
|
|
353
701
|
"""Return the source content of a code-mode script plus its metadata."""
|
|
@@ -394,12 +742,17 @@ async def execute_code_script(
|
|
|
394
742
|
try:
|
|
395
743
|
script_path = _validate_script_name(script_name)
|
|
396
744
|
except ValueError as e:
|
|
397
|
-
return str(e)
|
|
745
|
+
return _contract_result("INVALID_INPUT", str(e), json_output)
|
|
398
746
|
|
|
399
747
|
metadata = _parse_frontmatter(script_path)
|
|
400
748
|
denied = _validate_args(script_path, args)
|
|
401
749
|
if denied is not None:
|
|
402
|
-
return denied
|
|
750
|
+
return _contract_result("INVALID_INPUT", denied, json_output)
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
_verify_approved(script_path)
|
|
754
|
+
except ManifestError as e:
|
|
755
|
+
return _contract_result(e.status, e.message, json_output)
|
|
403
756
|
|
|
404
757
|
timeout_s = _script_timeout(script_path)
|
|
405
758
|
|
|
@@ -455,8 +808,15 @@ async def execute_code_script(
|
|
|
455
808
|
await proc.wait()
|
|
456
809
|
duration_ms = int((time.monotonic() - started) * 1000)
|
|
457
810
|
return _build_result(
|
|
458
|
-
"",
|
|
459
|
-
|
|
811
|
+
"",
|
|
812
|
+
"",
|
|
813
|
+
-1,
|
|
814
|
+
timed_out=True,
|
|
815
|
+
duration_ms=duration_ms,
|
|
816
|
+
metadata=metadata,
|
|
817
|
+
json_output=json_output,
|
|
818
|
+
timeout_s=timeout_s,
|
|
819
|
+
status="TIMEOUT",
|
|
460
820
|
)
|
|
461
821
|
except FileNotFoundError:
|
|
462
822
|
return _build_result(
|
|
@@ -468,6 +828,7 @@ async def execute_code_script(
|
|
|
468
828
|
metadata=metadata,
|
|
469
829
|
json_output=json_output,
|
|
470
830
|
timeout_s=timeout_s,
|
|
831
|
+
status="UNAVAILABLE",
|
|
471
832
|
)
|
|
472
833
|
except OSError as e:
|
|
473
834
|
return _build_result(
|
|
@@ -479,10 +840,56 @@ async def execute_code_script(
|
|
|
479
840
|
metadata=metadata,
|
|
480
841
|
json_output=json_output,
|
|
481
842
|
timeout_s=timeout_s,
|
|
843
|
+
status="UNAVAILABLE",
|
|
482
844
|
)
|
|
483
845
|
|
|
484
846
|
|
|
847
|
+
@mcp.tool(
|
|
848
|
+
name="approve_code_script",
|
|
849
|
+
description="Approve a .sh/.py script for execution by adding it to the "
|
|
850
|
+
"code-mode manifest with its SHA-256. Execution is opt-in: only approved "
|
|
851
|
+
"scripts with a matching hash run. Set json_output=true for structured JSON.",
|
|
852
|
+
)
|
|
853
|
+
async def approve_code_script(
|
|
854
|
+
script_name: str,
|
|
855
|
+
json_output: bool = False,
|
|
856
|
+
) -> str | dict[str, Any]:
|
|
857
|
+
"""Approve (or re-approve) a code-mode script.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
script_name: Name of the script in the code-mode directory.
|
|
861
|
+
json_output: When True, return ``{"status", "message"}`` instead of a
|
|
862
|
+
``[STATUS] message`` string.
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
A contract-labelled result. ``OK`` on success, ``INVALID_INPUT`` when
|
|
866
|
+
the script name is invalid, or ``CORRUPT_DATA``/``UNAVAILABLE`` when
|
|
867
|
+
the manifest cannot be read or written.
|
|
868
|
+
"""
|
|
869
|
+
status, message = _approve_script(script_name)
|
|
870
|
+
if json_output:
|
|
871
|
+
return {"status": status, "message": message}
|
|
872
|
+
return f"[{status}] {message}"
|
|
873
|
+
|
|
874
|
+
|
|
485
875
|
# ── Main Entrypoint ───────────────────────────────────────────────────────────
|
|
486
876
|
|
|
487
|
-
|
|
877
|
+
|
|
878
|
+
def _cli() -> int:
|
|
879
|
+
"""Handle CLI subcommands used by the installer; fall back to serving MCP."""
|
|
880
|
+
argv = sys.argv[1:]
|
|
881
|
+
if "--generate-manifest" in argv:
|
|
882
|
+
scripts_dir: Path | None = None
|
|
883
|
+
if "--scripts-dir" in argv:
|
|
884
|
+
index = argv.index("--scripts-dir")
|
|
885
|
+
if index + 1 < len(argv):
|
|
886
|
+
scripts_dir = Path(argv[index + 1])
|
|
887
|
+
count = _generate_manifest(scripts_dir)
|
|
888
|
+
print(f"code-mode manifest: {count} script(s) approved")
|
|
889
|
+
return 0
|
|
488
890
|
mcp.run()
|
|
891
|
+
return 0
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
if __name__ == "__main__":
|
|
895
|
+
raise SystemExit(_cli())
|
package/src/mcp/eval_store.py
CHANGED
|
@@ -138,7 +138,7 @@ def store_eval(
|
|
|
138
138
|
except sqlite3.IntegrityError:
|
|
139
139
|
conn.rollback()
|
|
140
140
|
return {"error": f"Duplicate key or constraint violation: {key}"}
|
|
141
|
-
except Exception as e:
|
|
141
|
+
except Exception as e:
|
|
142
142
|
conn.rollback()
|
|
143
143
|
return {"error": f"Failed to store eval: {e}"}
|
|
144
144
|
finally:
|