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.
@@ -13,7 +13,7 @@ import { execSync } from 'node:child_process'
13
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
14
14
  import { homedir } from 'node:os'
15
15
  import { join } from 'node:path'
16
- import { fileURLToPath } from 'node:url'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
17
 
18
18
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
19
19
  const ROOT = join(__dirname, '..')
@@ -106,46 +106,90 @@ function copyPluginFiles(srcDir, dstDir) {
106
106
  return result
107
107
  }
108
108
 
109
- // ── Main ───────────────────────────────────────────────────────────────
109
+ // ── npm env hygiene ────────────────────────────────────────────────────
110
110
 
111
- try {
112
- const configDir = resolveConfigDir()
113
- if (!configDir) {
114
- // User hasn't run init yet — silent exit, no error
115
- process.exit(0)
116
- }
111
+ /**
112
+ * npm lifecycle env vars that put a nested `npm ci` into global context and
113
+ * make it abort with ECIGLOBAL ("`npm ci` does not work for global packages").
114
+ * When the package itself is installed with `npm install -g`, npm injects these
115
+ * into the postinstall environment; the TUI dependency sync is a local
116
+ * operation and must never inherit them.
117
+ */
118
+ const GLOBAL_NPM_ENV_KEYS = new Set([
119
+ 'npm_config_global',
120
+ 'npm_config_globalconfig',
121
+ 'npm_config_prefix',
122
+ 'npm_config_location',
123
+ ])
117
124
 
118
- const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
119
- if (!existsSync(tuiCopyDir)) {
120
- // TUI plugin not installed — silent exit
121
- process.exit(0)
125
+ /**
126
+ * Return a copy of `env` with global-context npm config vars removed.
127
+ * Matching is case-insensitive so Windows-style uppercase keys are handled.
128
+ * @param {NodeJS.ProcessEnv} [env]
129
+ * @returns {NodeJS.ProcessEnv}
130
+ */
131
+ export function sanitizeNpmEnv(env = process.env) {
132
+ const clean = { ...env }
133
+ for (const key of Object.keys(clean)) {
134
+ if (GLOBAL_NPM_ENV_KEYS.has(key.toLowerCase())) delete clean[key]
122
135
  }
136
+ return clean
137
+ }
123
138
 
124
- // Source: TUI plugin inside the installed package
125
- const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
126
- if (!existsSync(tuiSrcDir)) {
127
- throw new Error(`TUI plugin source not found: ${tuiSrcDir}`)
128
- }
139
+ // ── Main ───────────────────────────────────────────────────────────────
129
140
 
130
- // Compare versions before copy
131
- const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
132
- const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
141
+ function main() {
142
+ try {
143
+ const configDir = resolveConfigDir()
144
+ if (!configDir) {
145
+ // User hasn't run init yet — silent exit, no error
146
+ process.exit(0)
147
+ }
133
148
 
134
- // Copy fresh files
135
- const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
149
+ const tuiCopyDir = join(configDir, 'plugins', 'pantheon-tui')
150
+ if (!existsSync(tuiCopyDir)) {
151
+ // TUI plugin not installed — silent exit
152
+ process.exit(0)
153
+ }
136
154
 
137
- // Lockfile installation is the only accepted dependency path.
138
- execSync('npm ci --omit=dev --no-audit --no-fund', { cwd: tuiCopyDir, stdio: 'pipe' })
155
+ // Source: TUI plugin inside the installed package
156
+ const tuiSrcDir = join(ROOT, 'src', 'plugins', 'tui')
157
+ if (!existsSync(tuiSrcDir)) {
158
+ throw new Error(`TUI plugin source not found: ${tuiSrcDir}`)
159
+ }
139
160
 
140
- // Log result
141
- if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
142
- console.log(` TUI plugin already up to date (v${sourceVersion})`)
143
- } else {
144
- console.log(
145
- ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
146
- )
161
+ // Compare versions before copy
162
+ const installedVersion = readVersion(join(tuiCopyDir, 'package.json'))
163
+ const sourceVersion = readVersion(join(tuiSrcDir, 'package.json'))
164
+
165
+ // Copy fresh files
166
+ const { created } = copyPluginFiles(tuiSrcDir, tuiCopyDir)
167
+
168
+ // Lockfile installation is the only accepted dependency path. Strip
169
+ // global-context npm vars so a global postinstall can't trigger ECIGLOBAL.
170
+ execSync('npm ci --omit=dev --no-audit --no-fund', {
171
+ cwd: tuiCopyDir,
172
+ stdio: 'pipe',
173
+ env: sanitizeNpmEnv(),
174
+ })
175
+
176
+ // Log result
177
+ if (installedVersion && sourceVersion && installedVersion === sourceVersion && created === 0) {
178
+ console.log(` TUI plugin already up to date (v${sourceVersion})`)
179
+ } else {
180
+ console.log(
181
+ ` TUI plugin updated to v${sourceVersion || 'latest'}${installedVersion ? ` (was v${installedVersion})` : ''}`,
182
+ )
183
+ }
184
+ } catch (err) {
185
+ console.error(`❌ TUI sync failed: ${err.message}`)
186
+ process.exitCode = 1
147
187
  }
148
- } catch (err) {
149
- console.error(`❌ TUI sync failed: ${err.message}`)
150
- process.exitCode = 1
151
188
  }
189
+
190
+ // Only run when invoked directly (`node scripts/sync-tui.mjs`). Importing this
191
+ // module for unit tests must not trigger the side-effecting postinstall sync.
192
+ const isDirectRun =
193
+ Boolean(process.argv[1]) && pathToFileURL(process.argv[1]).href === import.meta.url
194
+
195
+ if (isDirectRun) main()
@@ -329,8 +329,8 @@ nunca redispara. Apos 1 retry vazio → escalate (mesma regra do TODO Enforcer).
329
329
  > Uso baixo-nível: `createZeusRetryHelper().executeWithRetry(first, () => secondRead())` — classifica empty e decide retry/escalate (mesmo cap 1).
330
330
 
331
331
  ### /cost — pantheon_cost tool (WIRED no plugin)
332
- `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY (node:sqlite;
333
- fallback `scripts/cost.mjs`) e devolve tabela markdown de custo + tokens por
332
+ `pantheon_cost({ days?: number })` le o `opencode.db` READ-ONLY usando o backend
333
+ unico `node:sqlite`; se indisponivel, retorna `UNSUPPORTED`, sem fallback. Devolve tabela markdown de custo + tokens por
334
334
  agente nos ultimos N dias (default 7). Uso: quando Nyx pedir visibilidade de
335
335
  custo, ou antes de escalar tier — decida se o batch valeu o preco.
336
336
 
@@ -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
- Raises ValueError if the name is invalid, traverses paths, or
240
- has a disallowed extension.
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 SCRIPTS_DIR.is_symlink():
289
+ if scripts_dir.is_symlink():
257
290
  raise ValueError(f"Invalid script name: '{script_name}'")
258
291
 
259
292
  try:
260
- scripts_root = SCRIPTS_DIR.resolve(strict=True)
261
- script_path = (SCRIPTS_DIR / name).resolve(strict=True)
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
- if __name__ == "__main__":
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
+
@@ -272,6 +272,20 @@ export class BackgroundJobBoard {
272
272
  return record
273
273
  }
274
274
 
275
+ /**
276
+ * Register a launch only when taskID has not already been claimed.
277
+ *
278
+ * The map check and set happen before the first await, so JavaScript's
279
+ * single-threaded event loop makes this critical section atomic even when
280
+ * two event handlers call it concurrently. The existing record is returned
281
+ * for the losing caller.
282
+ */
283
+ async registerLaunchIfAbsent(input: LaunchInput): Promise<BackgroundJobRecord> {
284
+ const existing = this.jobs.get(input.taskID)
285
+ if (existing !== undefined) return existing
286
+ return this.registerLaunch(input)
287
+ }
288
+
275
289
  /**
276
290
  * Update a job's state to a terminal state.
277
291
  *