pantheon-opencode 1.5.0-beta.3 → 1.5.0-beta.4
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/manifest.json +21 -0
- package/CHANGELOG.md +835 -0
- package/README.md +62 -0
- package/README.pt-BR.md +18 -1
- package/package.json +2 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/code_mode_server.py +305 -9
- package/scripts/install/opencode.mjs +36 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/sync-tui.mjs +78 -34
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +305 -9
- package/src/pantheon/background-job-board.ts +14 -0
- package/src/pantheon/cost-command.ts +62 -42
- package/src/pantheon/delegate-manager.ts +109 -12
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +18 -110
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +10 -1
- package/src/plugin.ts +19 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,57 @@ availability and configuration of OpenCode and any optional services you choose
|
|
|
76
76
|
to use. Check the [releases](https://github.com/ils15/pantheon-opencode/releases)
|
|
77
77
|
and [changelog](CHANGELOG.md) for the latest published changes.
|
|
78
78
|
|
|
79
|
+
## Native delegation mode
|
|
80
|
+
|
|
81
|
+
Set `PANTHEON_DELEGATE_MODE=native` to use OpenCode's native child-session
|
|
82
|
+
delegation. Native mode is strict: it does not use foreground fallback, model
|
|
83
|
+
failover, or retry; it performs a lazy capability probe on the first dispatch,
|
|
84
|
+
and includes the resulting status in the delegation receipt.
|
|
85
|
+
|
|
86
|
+
## Cost tool backend
|
|
87
|
+
|
|
88
|
+
`pantheon_cost` has one backend: read-only `node:sqlite` against the selected
|
|
89
|
+
`opencode.db`. On Node versions without `node:sqlite` (Node < 22.5), the tool
|
|
90
|
+
returns `status: UNSUPPORTED`; it has no CLI fallback. Database failures return
|
|
91
|
+
a contract status such as `UNAVAILABLE` or
|
|
92
|
+
`CORRUPT_DATA` and include diagnostic detail, including captured stderr when
|
|
93
|
+
available.
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
## Code-mode execution (explicit opt-in)
|
|
97
|
+
|
|
98
|
+
Scripts run through the `pantheon-code-mode` MCP server (`execute_code_script`)
|
|
99
|
+
only when they are explicitly approved. Approval is recorded in
|
|
100
|
+
`.pantheon/code-mode/manifest.json` with the SHA-256 of each script:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"version": 1,
|
|
105
|
+
"scripts": {
|
|
106
|
+
"example-sync.sh": "<sha256>"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- **No manifest → nothing executes.** A missing manifest returns
|
|
112
|
+
`INVALID_STATE`; scripts are never run implicitly.
|
|
113
|
+
- **Not listed → `CONFLICT`.** The script must be approved first.
|
|
114
|
+
- **Hash mismatch → `CORRUPT_DATA`.** The SHA-256 of the file on disk must
|
|
115
|
+
match the manifest entry, so edits after approval are detected.
|
|
116
|
+
|
|
117
|
+
Approve or re-approve a script with the `approve_code_script` MCP tool:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
approve_code_script("checkpoint-session.sh")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The installer seeds the manifest with the SHA-256 of every bundled script, so
|
|
124
|
+
shipped scripts stay approved across installs. Re-run `approve_code_script`
|
|
125
|
+
after intentionally editing a script. Status codes follow the shared nine-code
|
|
126
|
+
contract (`OK`, `UNSUPPORTED`, `UNAVAILABLE`, `INVALID_INPUT`, `INVALID_STATE`,
|
|
127
|
+
`CONFLICT`, `CORRUPT_DATA`, `TIMEOUT`, `ESCALATE`); with `json_output=true`,
|
|
128
|
+
results carry a `status` field.
|
|
129
|
+
|
|
79
130
|
|
|
80
131
|
## What's new in 1.5.0-beta.2
|
|
81
132
|
|
|
@@ -218,6 +269,17 @@ optional codemap schema and `code_index`, `code_query`, and `code_neighbors`.
|
|
|
218
269
|
The other shared MCP copies remain identical. Do not overwrite one memory copy
|
|
219
270
|
with the other.
|
|
220
271
|
|
|
272
|
+
### Task-result guard
|
|
273
|
+
|
|
274
|
+
A `task-result-guard` intercepts calls to the native `task()` tool where the
|
|
275
|
+
child session returns an empty or missing result. Instead of surfacing a silent
|
|
276
|
+
`completed` with no payload (which confuses the orchestrator), the guard now
|
|
277
|
+
raises an explicit error. This catches the common free-tier failure mode where a
|
|
278
|
+
child session exceeds the uncached-prefill token budget (`BackendAdmissionRejected`)
|
|
279
|
+
and returns nothing. For large payloads, prefer `pantheon_delegate` over native
|
|
280
|
+
`task()` — the delegation layer has better timeout and error handling.
|
|
281
|
+
|
|
282
|
+
|
|
221
283
|
## Documentation
|
|
222
284
|
|
|
223
285
|
- [Installation](docs/INSTALLATION.md) · [Quick start](docs/QUICKSTART.md)
|
package/README.pt-BR.md
CHANGED
|
@@ -78,6 +78,23 @@ escolher. Veja as [releases](https://github.com/ils15/pantheon-opencode/releases
|
|
|
78
78
|
e o [changelog](CHANGELOG.md) para acompanhar as mudanças publicadas.
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
## Execução code-mode (opt-in explícito)
|
|
82
|
+
|
|
83
|
+
Scripts executados pelo MCP server `pantheon-code-mode` (`execute_code_script`)
|
|
84
|
+
só rodam quando aprovados explicitamente. A aprovação fica em
|
|
85
|
+
`.pantheon/code-mode/manifest.json` com o SHA-256 de cada script.
|
|
86
|
+
|
|
87
|
+
- **Sem manifest → nada executa** (`INVALID_STATE`); nunca há execução implícita.
|
|
88
|
+
- **Script fora do manifest → `CONFLICT`**; é preciso aprovar antes.
|
|
89
|
+
- **Hash divergente → `CORRUPT_DATA`**; edições após a aprovação são detectadas.
|
|
90
|
+
|
|
91
|
+
Aprove (ou reaprove) um script com a tool `approve_code_script`. O instalador
|
|
92
|
+
semeia o manifest com o hash de todos os scripts empacotados. Os resultados
|
|
93
|
+
seguem o contrato de nove códigos (`OK`, `UNSUPPORTED`, `UNAVAILABLE`,
|
|
94
|
+
`INVALID_INPUT`, `INVALID_STATE`, `CONFLICT`, `CORRUPT_DATA`, `TIMEOUT`,
|
|
95
|
+
`ESCALATE`) e, com `json_output=true`, incluem o campo `status`.
|
|
96
|
+
|
|
97
|
+
|
|
81
98
|
## Novidades da 1.5.0-beta.2
|
|
82
99
|
|
|
83
100
|
- Instalador exclusivo para OpenCode: guias de plataformas consolidados em um
|
|
@@ -142,7 +159,7 @@ instalador remove referências Pantheon das duas formas de config antes de
|
|
|
142
159
|
gravar apenas o registro Pantheon selecionado. Entradas de terceiros não são
|
|
143
160
|
convertidas nem reivindicadas por essa regra.
|
|
144
161
|
|
|
145
|
-
O relatório `pantheon_cost
|
|
162
|
+
O relatório `pantheon_cost`, disponível nos plugins V1 e V2, pode selecionar o banco com
|
|
146
163
|
`PANTHEON_OPENCODE_VERSION=v1` ou `v2` (`opencode.db` ou `opencode-v2.db`).
|
|
147
164
|
`PANTHEON_COST_DB=/caminho/absoluto/para/opencode.db` tem precedência sobre o
|
|
148
165
|
seletor de versão, e um `dbPath` explícito fornecido pelo chamador da
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon-opencode",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.4",
|
|
4
4
|
"description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"!src/agents/README.md",
|
|
69
69
|
"bin/*.mjs",
|
|
70
70
|
"AGENTS.md",
|
|
71
|
+
"CHANGELOG.md",
|
|
71
72
|
"opencode.json",
|
|
72
73
|
"src/mcp/*.py",
|
|
73
74
|
"src/instructions/*.instructions.md",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pantheon",
|
|
3
3
|
"publisher": "ils15",
|
|
4
|
-
"version": "1.5.0-beta.
|
|
4
|
+
"version": "1.5.0-beta.4",
|
|
5
5
|
"displayName": "Pantheon",
|
|
6
6
|
"description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
|
|
7
7
|
"agents": "./src/agents",
|
package/pyproject.toml
CHANGED
|
@@ -16,6 +16,8 @@ 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
23
|
import shutil
|
|
@@ -38,6 +40,30 @@ SCRIPT_TIMEOUT: int = 30
|
|
|
38
40
|
# executor for longer than 5 minutes.
|
|
39
41
|
MAX_SCRIPT_TIMEOUT: int = 300
|
|
40
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
|
+
|
|
41
67
|
# ── Env Allowlist ──────────────────────────────────────────────────────────────
|
|
42
68
|
# Only these variables are passed to script subprocesses. Everything else
|
|
43
69
|
# (secrets, cloud credentials, etc.) is stripped to limit blast radius.
|
|
@@ -233,12 +259,19 @@ mcp = FastMCP(
|
|
|
233
259
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
234
260
|
|
|
235
261
|
|
|
236
|
-
def _validate_script_name(script_name: str) -> Path:
|
|
262
|
+
def _validate_script_name(script_name: str, scripts_dir: Path | None = None) -> Path:
|
|
237
263
|
"""Validate a script name and return its resolved path.
|
|
238
264
|
|
|
239
|
-
|
|
240
|
-
|
|
265
|
+
Args:
|
|
266
|
+
script_name: Bare file name of a script in the code-mode directory.
|
|
267
|
+
scripts_dir: Directory to resolve against. Defaults to ``SCRIPTS_DIR``.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: If the name is invalid, traverses paths, or has a
|
|
271
|
+
disallowed extension.
|
|
241
272
|
"""
|
|
273
|
+
scripts_dir = scripts_dir or SCRIPTS_DIR
|
|
274
|
+
|
|
242
275
|
if not script_name:
|
|
243
276
|
raise ValueError("Script name cannot be empty")
|
|
244
277
|
|
|
@@ -253,12 +286,12 @@ def _validate_script_name(script_name: str) -> Path:
|
|
|
253
286
|
allowed = ", ".join(sorted(ALLOWED_EXTENSIONS))
|
|
254
287
|
raise ValueError(f"Extension '{ext}' not allowed. Allowed: {allowed}")
|
|
255
288
|
|
|
256
|
-
if
|
|
289
|
+
if scripts_dir.is_symlink():
|
|
257
290
|
raise ValueError(f"Invalid script name: '{script_name}'")
|
|
258
291
|
|
|
259
292
|
try:
|
|
260
|
-
scripts_root =
|
|
261
|
-
script_path = (
|
|
293
|
+
scripts_root = scripts_dir.resolve(strict=True)
|
|
294
|
+
script_path = (scripts_dir / name).resolve(strict=True)
|
|
262
295
|
except (FileNotFoundError, OSError, RuntimeError):
|
|
263
296
|
raise ValueError(f"Script '{script_name}' not found") from None
|
|
264
297
|
|
|
@@ -272,6 +305,213 @@ def _validate_script_name(script_name: str) -> Path:
|
|
|
272
305
|
return script_path
|
|
273
306
|
|
|
274
307
|
|
|
308
|
+
# ── Installation Mode: Manifest + SHA-256 ─────────────────────────────────────
|
|
309
|
+
# The manifest is the explicit opt-in. A script is executable only when its
|
|
310
|
+
# bare name is present in `manifest.json` AND the SHA-256 of the on-disk file
|
|
311
|
+
# matches the recorded digest. Any deviation fails closed with a contract
|
|
312
|
+
# status instead of executing.
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ManifestError(Exception):
|
|
316
|
+
"""Raised when the code-mode manifest cannot authorise execution."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, status: str, message: str) -> None:
|
|
319
|
+
super().__init__(message)
|
|
320
|
+
self.status = status
|
|
321
|
+
self.message = message
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _manifest_path(scripts_dir: Path | None = None) -> Path:
|
|
325
|
+
"""Return the manifest path for *scripts_dir* (defaults to SCRIPTS_DIR)."""
|
|
326
|
+
return (scripts_dir or SCRIPTS_DIR) / MANIFEST_FILENAME
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _sha256_file(path: Path) -> str:
|
|
330
|
+
"""Return the hex SHA-256 digest of *path* (streamed, binary-safe)."""
|
|
331
|
+
digest = hashlib.sha256()
|
|
332
|
+
with path.open("rb") as handle:
|
|
333
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
334
|
+
digest.update(chunk)
|
|
335
|
+
return digest.hexdigest()
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _load_manifest(scripts_dir: Path | None = None) -> dict[str, str]:
|
|
339
|
+
"""Load and validate the manifest, returning ``{script_name: sha256}``.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
ManifestError: With ``INVALID_STATE`` when the manifest is absent (no
|
|
343
|
+
explicit opt-in), or ``CORRUPT_DATA`` when it is unreadable or
|
|
344
|
+
malformed.
|
|
345
|
+
"""
|
|
346
|
+
manifest_file = _manifest_path(scripts_dir)
|
|
347
|
+
if not manifest_file.is_file():
|
|
348
|
+
raise ManifestError(
|
|
349
|
+
"INVALID_STATE",
|
|
350
|
+
f"Code-mode manifest not found at {manifest_file}. No script executes "
|
|
351
|
+
"without explicit opt-in — run approve_code_script first.",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
raw = manifest_file.read_text(encoding="utf-8")
|
|
356
|
+
except OSError as exc:
|
|
357
|
+
raise ManifestError(
|
|
358
|
+
"CORRUPT_DATA", f"Code-mode manifest unreadable: {exc}"
|
|
359
|
+
) from None
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
data = json.loads(raw)
|
|
363
|
+
except (json.JSONDecodeError, ValueError):
|
|
364
|
+
raise ManifestError(
|
|
365
|
+
"CORRUPT_DATA",
|
|
366
|
+
f"Code-mode manifest is malformed JSON: {manifest_file}",
|
|
367
|
+
) from None
|
|
368
|
+
|
|
369
|
+
if not isinstance(data, dict):
|
|
370
|
+
raise ManifestError(
|
|
371
|
+
"CORRUPT_DATA", "Code-mode manifest must be a JSON object."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
scripts = data.get("scripts")
|
|
375
|
+
if not isinstance(scripts, dict):
|
|
376
|
+
raise ManifestError(
|
|
377
|
+
"CORRUPT_DATA",
|
|
378
|
+
"Code-mode manifest is malformed: missing 'scripts' object.",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
approved: dict[str, str] = {}
|
|
382
|
+
for name, digest in scripts.items():
|
|
383
|
+
if (
|
|
384
|
+
not isinstance(name, str)
|
|
385
|
+
or not isinstance(digest, str)
|
|
386
|
+
or len(digest) != _SHA256_HEX_LEN
|
|
387
|
+
):
|
|
388
|
+
raise ManifestError(
|
|
389
|
+
"CORRUPT_DATA",
|
|
390
|
+
f"Code-mode manifest entry for '{name}' is malformed "
|
|
391
|
+
"(expected a 64-character SHA-256 hex digest).",
|
|
392
|
+
)
|
|
393
|
+
approved[name] = digest.lower()
|
|
394
|
+
return approved
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _verify_approved(script_path: Path, scripts_dir: Path | None = None) -> None:
|
|
398
|
+
"""Authorise *script_path* against the manifest.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
ManifestError: ``CONFLICT`` when the script is not listed and
|
|
402
|
+
``CORRUPT_DATA`` when its hash does not match the recorded digest.
|
|
403
|
+
"""
|
|
404
|
+
approved = _load_manifest(scripts_dir)
|
|
405
|
+
name = script_path.name
|
|
406
|
+
if name not in approved:
|
|
407
|
+
raise ManifestError(
|
|
408
|
+
"CONFLICT",
|
|
409
|
+
f"Script '{name}' is not approved in the code-mode manifest. "
|
|
410
|
+
"Run approve_code_script to opt in.",
|
|
411
|
+
)
|
|
412
|
+
actual = _sha256_file(script_path)
|
|
413
|
+
if actual != approved[name]:
|
|
414
|
+
raise ManifestError(
|
|
415
|
+
"CORRUPT_DATA",
|
|
416
|
+
f"Script '{name}' hash mismatch (expected {approved[name]}, got "
|
|
417
|
+
f"{actual}). Re-approve the script after verifying the change.",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _approve_script(
|
|
422
|
+
script_name: str, scripts_dir: Path | None = None
|
|
423
|
+
) -> tuple[str, str]:
|
|
424
|
+
"""Add or refresh *script_name* in the manifest with its SHA-256.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
A ``(status, message)`` tuple; the status is a contract code and is
|
|
428
|
+
``OK`` on success.
|
|
429
|
+
"""
|
|
430
|
+
target_dir = scripts_dir or SCRIPTS_DIR
|
|
431
|
+
try:
|
|
432
|
+
script_path = _validate_script_name(script_name, target_dir)
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
return "INVALID_INPUT", str(exc)
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
approved = _load_manifest(target_dir)
|
|
438
|
+
except ManifestError as exc:
|
|
439
|
+
if exc.status == "INVALID_STATE":
|
|
440
|
+
approved = {}
|
|
441
|
+
else:
|
|
442
|
+
return exc.status, exc.message
|
|
443
|
+
|
|
444
|
+
approved[script_path.name] = _sha256_file(script_path)
|
|
445
|
+
payload = {
|
|
446
|
+
"version": MANIFEST_VERSION,
|
|
447
|
+
"scripts": dict(sorted(approved.items())),
|
|
448
|
+
}
|
|
449
|
+
try:
|
|
450
|
+
_manifest_path(target_dir).write_text(
|
|
451
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
452
|
+
)
|
|
453
|
+
except OSError as exc:
|
|
454
|
+
return "UNAVAILABLE", f"Failed to write code-mode manifest: {exc}"
|
|
455
|
+
|
|
456
|
+
digest = approved[script_path.name]
|
|
457
|
+
return "OK", f"Approved '{script_path.name}' with SHA-256 {digest}"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _generate_manifest(scripts_dir: Path | None = None) -> int:
|
|
461
|
+
"""Write a manifest covering every valid script in *scripts_dir*.
|
|
462
|
+
|
|
463
|
+
Used by the installer to (re)seed the explicit opt-in for bundled scripts.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
The number of scripts recorded.
|
|
467
|
+
"""
|
|
468
|
+
target_dir = scripts_dir or SCRIPTS_DIR
|
|
469
|
+
try:
|
|
470
|
+
resolved_dir = target_dir.resolve(strict=True)
|
|
471
|
+
except (OSError, RuntimeError):
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
approved: dict[str, str] = {}
|
|
475
|
+
if resolved_dir.is_dir() and not resolved_dir.is_symlink():
|
|
476
|
+
for candidate in sorted(resolved_dir.iterdir()):
|
|
477
|
+
if candidate.name.startswith("."):
|
|
478
|
+
continue
|
|
479
|
+
if candidate.suffix.lower() not in ALLOWED_EXTENSIONS:
|
|
480
|
+
continue
|
|
481
|
+
try:
|
|
482
|
+
resolved = candidate.resolve(strict=True)
|
|
483
|
+
except (OSError, RuntimeError):
|
|
484
|
+
continue
|
|
485
|
+
if (
|
|
486
|
+
resolved.is_file()
|
|
487
|
+
and resolved.parent == resolved_dir
|
|
488
|
+
and _is_within(resolved, resolved_dir)
|
|
489
|
+
):
|
|
490
|
+
approved[candidate.name] = _sha256_file(resolved)
|
|
491
|
+
|
|
492
|
+
payload = {
|
|
493
|
+
"version": MANIFEST_VERSION,
|
|
494
|
+
"scripts": dict(sorted(approved.items())),
|
|
495
|
+
}
|
|
496
|
+
try:
|
|
497
|
+
_manifest_path(target_dir).write_text(
|
|
498
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
499
|
+
)
|
|
500
|
+
except OSError:
|
|
501
|
+
return 0
|
|
502
|
+
return len(approved)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _contract_result(
|
|
506
|
+
status: str, message: str, json_output: bool
|
|
507
|
+
) -> str | dict[str, Any]:
|
|
508
|
+
"""Build a contract-labelled error result (text or structured JSON)."""
|
|
509
|
+
if json_output:
|
|
510
|
+
return {"status": status, "error": message}
|
|
511
|
+
return f"[{status}] {message}"
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
275
515
|
def _format_output(
|
|
276
516
|
stdout: str,
|
|
277
517
|
stderr: str,
|
|
@@ -395,10 +635,12 @@ def _build_result(
|
|
|
395
635
|
metadata: dict[str, Any],
|
|
396
636
|
json_output: bool,
|
|
397
637
|
timeout_s: int,
|
|
638
|
+
status: str = "OK",
|
|
398
639
|
) -> str | dict[str, Any]:
|
|
399
640
|
"""Build the tool result in plain-text or structured JSON form."""
|
|
400
641
|
if json_output:
|
|
401
642
|
return {
|
|
643
|
+
"status": status,
|
|
402
644
|
"stdout": stdout,
|
|
403
645
|
"stderr": stderr,
|
|
404
646
|
"exit_code": exit_code,
|
|
@@ -484,12 +726,17 @@ async def execute_code_script(
|
|
|
484
726
|
try:
|
|
485
727
|
script_path = _validate_script_name(script_name)
|
|
486
728
|
except ValueError as e:
|
|
487
|
-
return str(e)
|
|
729
|
+
return _contract_result("INVALID_INPUT", str(e), json_output)
|
|
488
730
|
|
|
489
731
|
metadata = _parse_frontmatter(script_path)
|
|
490
732
|
denied = _validate_args(script_path, args)
|
|
491
733
|
if denied is not None:
|
|
492
|
-
return denied
|
|
734
|
+
return _contract_result("INVALID_INPUT", denied, json_output)
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
_verify_approved(script_path)
|
|
738
|
+
except ManifestError as e:
|
|
739
|
+
return _contract_result(e.status, e.message, json_output)
|
|
493
740
|
|
|
494
741
|
timeout_s = _script_timeout(script_path)
|
|
495
742
|
|
|
@@ -553,6 +800,7 @@ async def execute_code_script(
|
|
|
553
800
|
metadata=metadata,
|
|
554
801
|
json_output=json_output,
|
|
555
802
|
timeout_s=timeout_s,
|
|
803
|
+
status="TIMEOUT",
|
|
556
804
|
)
|
|
557
805
|
except FileNotFoundError:
|
|
558
806
|
return _build_result(
|
|
@@ -564,6 +812,7 @@ async def execute_code_script(
|
|
|
564
812
|
metadata=metadata,
|
|
565
813
|
json_output=json_output,
|
|
566
814
|
timeout_s=timeout_s,
|
|
815
|
+
status="UNAVAILABLE",
|
|
567
816
|
)
|
|
568
817
|
except OSError as e:
|
|
569
818
|
return _build_result(
|
|
@@ -575,10 +824,57 @@ async def execute_code_script(
|
|
|
575
824
|
metadata=metadata,
|
|
576
825
|
json_output=json_output,
|
|
577
826
|
timeout_s=timeout_s,
|
|
827
|
+
status="UNAVAILABLE",
|
|
578
828
|
)
|
|
579
829
|
|
|
580
830
|
|
|
831
|
+
@mcp.tool(
|
|
832
|
+
name="approve_code_script",
|
|
833
|
+
description="Approve a .sh/.py script for execution by adding it to the "
|
|
834
|
+
"code-mode manifest with its SHA-256. Execution is opt-in: only approved "
|
|
835
|
+
"scripts with a matching hash run. Set json_output=true for structured JSON.",
|
|
836
|
+
)
|
|
837
|
+
async def approve_code_script(
|
|
838
|
+
script_name: str,
|
|
839
|
+
json_output: bool = False,
|
|
840
|
+
) -> str | dict[str, Any]:
|
|
841
|
+
"""Approve (or re-approve) a code-mode script.
|
|
842
|
+
|
|
843
|
+
Args:
|
|
844
|
+
script_name: Name of the script in the code-mode directory.
|
|
845
|
+
json_output: When True, return ``{"status", "message"}`` instead of a
|
|
846
|
+
``[STATUS] message`` string.
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
A contract-labelled result. ``OK`` on success, ``INVALID_INPUT`` when
|
|
850
|
+
the script name is invalid, or ``CORRUPT_DATA``/``UNAVAILABLE`` when
|
|
851
|
+
the manifest cannot be read or written.
|
|
852
|
+
"""
|
|
853
|
+
status, message = _approve_script(script_name)
|
|
854
|
+
if json_output:
|
|
855
|
+
return {"status": status, "message": message}
|
|
856
|
+
return f"[{status}] {message}"
|
|
857
|
+
|
|
858
|
+
|
|
581
859
|
# ── Main Entrypoint ───────────────────────────────────────────────────────────
|
|
582
860
|
|
|
583
|
-
|
|
861
|
+
|
|
862
|
+
def _cli() -> int:
|
|
863
|
+
"""Handle CLI subcommands used by the installer; fall back to serving MCP."""
|
|
864
|
+
argv = sys.argv[1:]
|
|
865
|
+
if "--generate-manifest" in argv:
|
|
866
|
+
scripts_dir: Path | None = None
|
|
867
|
+
if "--scripts-dir" in argv:
|
|
868
|
+
index = argv.index("--scripts-dir")
|
|
869
|
+
if index + 1 < len(argv):
|
|
870
|
+
scripts_dir = Path(argv[index + 1])
|
|
871
|
+
count = _generate_manifest(scripts_dir)
|
|
872
|
+
print(f"code-mode manifest: {count} script(s) approved")
|
|
873
|
+
return 0
|
|
584
874
|
mcp.run()
|
|
875
|
+
return 0
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
if __name__ == "__main__":
|
|
879
|
+
raise SystemExit(_cli())
|
|
880
|
+
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* isolation is handled at runtime via OPENCODE_DB.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
15
16
|
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
16
17
|
import { homedir } from 'node:os'
|
|
17
18
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
@@ -61,6 +62,34 @@ function deepClone(value) {
|
|
|
61
62
|
return JSON.parse(JSON.stringify(value))
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* (Re)generate the code-mode manifest with SHA-256 digests for every bundled
|
|
67
|
+
* script. This is the explicit installation opt-in: the MCP server only
|
|
68
|
+
* executes scripts listed here whose on-disk hash matches. Missing or empty
|
|
69
|
+
* directories approve nothing (0) — execution is never implicit.
|
|
70
|
+
*/
|
|
71
|
+
function writeCodeModeManifest(dir, dryRun) {
|
|
72
|
+
if (!existsSync(dir)) return 0
|
|
73
|
+
let names
|
|
74
|
+
try {
|
|
75
|
+
names = readdirSync(dir)
|
|
76
|
+
.filter((name) => /\.(sh|py)$/i.test(name) && !name.startsWith('.'))
|
|
77
|
+
.sort()
|
|
78
|
+
} catch {
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
if (names.length === 0) return 0
|
|
82
|
+
const scripts = {}
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
scripts[name] = createHash('sha256')
|
|
85
|
+
.update(readFileSync(join(dir, name)))
|
|
86
|
+
.digest('hex')
|
|
87
|
+
}
|
|
88
|
+
const payload = `${JSON.stringify({ version: 1, scripts }, null, 2)}\n`
|
|
89
|
+
writeIfChanged(join(dir, 'manifest.json'), payload, dryRun)
|
|
90
|
+
return names.length
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
function readJsonConfig(filePath) {
|
|
65
94
|
if (!existsSync(filePath)) return {}
|
|
66
95
|
|
|
@@ -665,6 +694,13 @@ export async function installOpenCode(
|
|
|
665
694
|
stats.created++
|
|
666
695
|
}
|
|
667
696
|
|
|
697
|
+
// B3-08: explicit installation mode — seed the manifest with SHA-256
|
|
698
|
+
// digests so the server never executes a script implicitly.
|
|
699
|
+
const approvedScripts = writeCodeModeManifest(dstCodeModeDir, dryRun)
|
|
700
|
+
if (approvedScripts > 0) {
|
|
701
|
+
info(`Code-mode manifest: ${approvedScripts} script(s) approved`)
|
|
702
|
+
}
|
|
703
|
+
|
|
668
704
|
// ── tiers.json ──
|
|
669
705
|
const srcTiers = join(ROOT, '.pantheon', 'tiers.json')
|
|
670
706
|
const dstTiers = join(runtimeTarget, '.pantheon', 'tiers.json')
|
|
@@ -28,6 +28,15 @@ function emit(status, detail, checks = []) {
|
|
|
28
28
|
if (status === 'FAIL') process.exitCode = 1
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function diagnostic(error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
33
|
+
const stderr =
|
|
34
|
+
error && typeof error === 'object' && typeof error.stderr === 'string'
|
|
35
|
+
? error.stderr.trim()
|
|
36
|
+
: ''
|
|
37
|
+
return stderr === '' ? message : `${message}; stderr: ${stderr}`
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
function ambiental(detail) {
|
|
32
41
|
emit('AMBIENTAL', detail)
|
|
33
42
|
}
|
|
@@ -295,7 +304,7 @@ async function main() {
|
|
|
295
304
|
checks.push('incompatible schema: PASS')
|
|
296
305
|
emit('PASS', 'offline synthetic DB checks passed; no real DB was read', checks)
|
|
297
306
|
} catch (error) {
|
|
298
|
-
emit('FAIL',
|
|
307
|
+
emit('FAIL', diagnostic(error), checks)
|
|
299
308
|
} finally {
|
|
300
309
|
rmSync(root, { recursive: true, force: true })
|
|
301
310
|
}
|