@agentsdock/server 1.0.7-beta.5 → 1.0.8

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 (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -11,7 +11,10 @@ from __future__ import annotations
11
11
  import hashlib
12
12
  import hmac
13
13
  import json
14
+ import os
14
15
  import re
16
+ import stat
17
+ import time
15
18
  import unicodedata
16
19
  from dataclasses import dataclass
17
20
  from pathlib import Path
@@ -24,9 +27,24 @@ MAX_PROVIDER_COMMAND_LABEL_CHARS = 160
24
27
  MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS = 800
25
28
  MAX_PROVIDER_COMMAND_PATH_CHARS = 4096
26
29
 
30
+ # OpenCode skills are discovered directly from its documented local defaults.
31
+ # These ceilings make opening the slash palette a bounded filesystem read even
32
+ # when a workspace contains a deliberately hostile directory tree.
33
+ MAX_OPENCODE_SKILL_ANCESTORS = 64
34
+ MAX_OPENCODE_SKILL_ROOTS = 3 * MAX_OPENCODE_SKILL_ANCESTORS + 3
35
+ MAX_OPENCODE_SKILL_SCAN_DEPTH = 8
36
+ MAX_OPENCODE_SKILL_SCAN_DIRS = 4096
37
+ MAX_OPENCODE_SKILL_SCAN_ENTRIES = 16384
38
+ MAX_OPENCODE_SKILL_FILE_BYTES = 1024 * 1024
39
+ MAX_OPENCODE_SKILL_TOTAL_BYTES = 16 * 1024 * 1024
40
+ MAX_OPENCODE_SKILL_FRONTMATTER_LINES = 256
41
+ OPENCODE_SKILL_SCAN_TIMEOUT_SECONDS = 2.0
42
+
27
43
  _COMMAND_NAME_RE = re.compile(
28
44
  rf"[A-Za-z0-9_][A-Za-z0-9_.:-]{{0,{MAX_PROVIDER_COMMAND_NAME_CHARS - 1}}}"
29
45
  )
46
+ _OPENCODE_SKILL_NAME_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
47
+ _FRONTMATTER_KEY_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]*):(?:[ \t]*(.*))?")
30
48
  _LOCATION_RE = re.compile(
31
49
  # Provider metadata is optional display copy, so redact the entire suffix
32
50
  # after a URL/path marker. Local paths may contain spaces and punctuation;
@@ -81,6 +99,58 @@ class ProviderCommandInventory:
81
99
  return None
82
100
 
83
101
 
102
+ class ProviderCommandDiscoveryError(RuntimeError):
103
+ """A bounded local provider inventory could not be read safely."""
104
+
105
+
106
+ @dataclass
107
+ class _OpenCodeSkillScanBudget:
108
+ deadline: float
109
+ directories: int = 0
110
+ entries: int = 0
111
+ files: int = 0
112
+ total_bytes: int = 0
113
+
114
+ def check(self) -> None:
115
+ if time.monotonic() > self.deadline:
116
+ raise ProviderCommandDiscoveryError(
117
+ "OpenCode skill discovery exceeded its time limit"
118
+ )
119
+
120
+ def count_directory(self) -> None:
121
+ self.check()
122
+ self.directories += 1
123
+ if self.directories > MAX_OPENCODE_SKILL_SCAN_DIRS:
124
+ raise ProviderCommandDiscoveryError(
125
+ "OpenCode skill discovery exceeded its directory limit"
126
+ )
127
+
128
+ def count_file(self, size: int) -> None:
129
+ self.check()
130
+ self.files += 1
131
+ if self.files > MAX_PROVIDER_COMMANDS:
132
+ raise ProviderCommandDiscoveryError(
133
+ "OpenCode skill discovery exceeded its file limit"
134
+ )
135
+ if size < 0 or size > MAX_OPENCODE_SKILL_FILE_BYTES:
136
+ raise ProviderCommandDiscoveryError(
137
+ "an OpenCode skill exceeds the per-file byte limit"
138
+ )
139
+ self.total_bytes += size
140
+ if self.total_bytes > MAX_OPENCODE_SKILL_TOTAL_BYTES:
141
+ raise ProviderCommandDiscoveryError(
142
+ "OpenCode skill discovery exceeded its aggregate byte limit"
143
+ )
144
+
145
+ def count_entry(self) -> None:
146
+ self.check()
147
+ self.entries += 1
148
+ if self.entries > MAX_OPENCODE_SKILL_SCAN_ENTRIES:
149
+ raise ProviderCommandDiscoveryError(
150
+ "OpenCode skill discovery exceeded its entry limit"
151
+ )
152
+
153
+
84
154
  def canonical_provider_command_name(value: Any) -> str | None:
85
155
  if not isinstance(value, str):
86
156
  return None
@@ -122,6 +192,515 @@ def sanitize_provider_command_text(value: Any, max_chars: int) -> str:
122
192
  return safe[: max(0, max_chars - 1)].rstrip() + "…"
123
193
 
124
194
 
195
+ def _plain_frontmatter_scalar(value: str) -> str | None:
196
+ """Parse the conservative scalar subset used by the local skill index.
197
+
198
+ OpenCode uses a complete YAML parser. AgentsServer intentionally accepts a
199
+ smaller, deterministic subset for the two display fields it needs rather
200
+ than adding executable tags, aliases, or another deployment dependency.
201
+ Files using richer YAML remain usable in OpenCode itself but are omitted
202
+ from this bounded palette.
203
+ """
204
+
205
+ candidate = value.strip()
206
+ if not candidate:
207
+ return ""
208
+ if candidate.startswith('"'):
209
+ try:
210
+ decoded = json.loads(candidate)
211
+ except (TypeError, json.JSONDecodeError):
212
+ return None
213
+ return decoded if isinstance(decoded, str) else None
214
+ if candidate.startswith("'"):
215
+ if len(candidate) < 2 or not candidate.endswith("'"):
216
+ return None
217
+ return candidate[1:-1].replace("''", "'")
218
+ if candidate[0] in "[{&*!|>":
219
+ return None
220
+ # A hash starts a YAML comment only when separated from the scalar.
221
+ candidate = re.split(r"[ \t]+#", candidate, maxsplit=1)[0].rstrip()
222
+ if re.search(r":[ \t]", candidate):
223
+ return None
224
+ if candidate.lower() in {"null", "true", "false", "~"}:
225
+ return None
226
+ return candidate
227
+
228
+
229
+ def _opencode_skill_frontmatter(data: bytes) -> tuple[str, str, str] | None:
230
+ try:
231
+ text = data.decode("utf-8", errors="strict")
232
+ except UnicodeDecodeError:
233
+ return None
234
+ if text.startswith("\ufeff"):
235
+ return None
236
+ lines = text.splitlines()
237
+ if not lines or lines[0].strip() != "---":
238
+ return None
239
+
240
+ closing = None
241
+ for index, line in enumerate(
242
+ lines[1 : MAX_OPENCODE_SKILL_FRONTMATTER_LINES + 1],
243
+ start=1,
244
+ ):
245
+ if line.strip() == "---":
246
+ closing = index
247
+ break
248
+ if closing is None:
249
+ return None
250
+
251
+ values: dict[str, str] = {}
252
+ body_lines = lines[1:closing]
253
+ index = 0
254
+ while index < len(body_lines):
255
+ line = body_lines[index]
256
+ index += 1
257
+ if not line.strip() or line.lstrip().startswith("#"):
258
+ continue
259
+ # Nested metadata and other unknown structured fields are irrelevant.
260
+ # A recognized field must be a top-level, single-line scalar.
261
+ if line[:1].isspace():
262
+ continue
263
+ match = _FRONTMATTER_KEY_RE.fullmatch(line)
264
+ if match is None:
265
+ continue
266
+ key, raw_value = match.groups()
267
+ if key not in {"name", "description"}:
268
+ continue
269
+ if key in values or raw_value is None:
270
+ return None
271
+ if key == "description" and raw_value.strip() in {
272
+ "|", "|-", "|+", ">", ">-", ">+",
273
+ }:
274
+ block: list[str] = []
275
+ while index < len(body_lines):
276
+ block_line = body_lines[index]
277
+ if block_line and not block_line[:1].isspace():
278
+ break
279
+ index += 1
280
+ block.append(block_line)
281
+ nonblank = [value for value in block if value.strip()]
282
+ if not nonblank or any(value.startswith("\t") for value in nonblank):
283
+ return None
284
+ indentation = min(len(value) - len(value.lstrip(" ")) for value in nonblank)
285
+ if indentation <= 0:
286
+ return None
287
+ value = "\n".join(
288
+ item[indentation:] if item.strip() else ""
289
+ for item in block
290
+ ).strip("\n")
291
+ values[key] = value
292
+ continue
293
+ value = _plain_frontmatter_scalar(raw_value)
294
+ if value is None:
295
+ return None
296
+ values[key] = value
297
+
298
+ name = values.get("name", "")
299
+ description = values.get("description", "")
300
+ if (
301
+ not 1 <= len(name) <= 64
302
+ or _OPENCODE_SKILL_NAME_RE.fullmatch(name) is None
303
+ or not 1 <= len(description) <= 1024
304
+ ):
305
+ return None
306
+ # OpenCode's native Skill loader passes gray-matter's trimmed Markdown
307
+ # content to the model, not the YAML frontmatter itself.
308
+ content = "\n".join(lines[closing + 1:]).strip()
309
+ return name, description, content
310
+
311
+
312
+ def _path_has_symlink(path: Path) -> bool:
313
+ """Return true when any existing component is a symbolic link."""
314
+
315
+ absolute = Path(os.path.abspath(os.path.normpath(str(path))))
316
+ parts = absolute.parts
317
+ if not parts:
318
+ return True
319
+ current = Path(parts[0])
320
+ for part in parts[1:]:
321
+ current /= part
322
+ try:
323
+ if stat.S_ISLNK(current.lstat().st_mode):
324
+ return True
325
+ except OSError:
326
+ return True
327
+ return False
328
+
329
+
330
+ def _read_regular_file_without_symlinks(
331
+ path: Path,
332
+ budget: _OpenCodeSkillScanBudget,
333
+ ) -> bytes:
334
+ # Pin each ancestor with openat rather than checking a path and opening it
335
+ # later: O_NOFOLLOW on only the leaf does not stop an ancestor being
336
+ # replaced by a symlink between those operations. O_NONBLOCK also keeps a
337
+ # regular file replaced by a FIFO from hanging before fstat can reject it.
338
+ required_flags = ("O_NOFOLLOW", "O_DIRECTORY", "O_NONBLOCK")
339
+ if any(not getattr(os, flag, 0) for flag in required_flags):
340
+ raise ProviderCommandDiscoveryError(
341
+ "safe OpenCode skill file opening is unavailable"
342
+ )
343
+ absolute = Path(os.path.abspath(os.path.normpath(str(path))))
344
+ parts = absolute.parts
345
+ if len(parts) < 2:
346
+ raise ProviderCommandDiscoveryError(
347
+ "an OpenCode skill path is not a file"
348
+ )
349
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | os.O_NOFOLLOW | os.O_NONBLOCK
350
+ directory_flags = flags | os.O_DIRECTORY
351
+ directory_fd: int | None = None
352
+ try:
353
+ budget.check()
354
+ directory_fd = os.open(parts[0], directory_flags)
355
+ for component in parts[1:-1]:
356
+ budget.check()
357
+ child_fd = os.open(component, directory_flags, dir_fd=directory_fd)
358
+ os.close(directory_fd)
359
+ directory_fd = child_fd
360
+ budget.check()
361
+ descriptor = os.open(parts[-1], flags, dir_fd=directory_fd)
362
+ except (OSError, NotImplementedError, TypeError) as exc:
363
+ raise ProviderCommandDiscoveryError(
364
+ "an OpenCode skill could not be opened safely"
365
+ ) from exc
366
+ finally:
367
+ if directory_fd is not None:
368
+ os.close(directory_fd)
369
+ try:
370
+ before = os.fstat(descriptor)
371
+ if not stat.S_ISREG(before.st_mode):
372
+ raise ProviderCommandDiscoveryError(
373
+ "an OpenCode skill is not a regular file"
374
+ )
375
+ budget.count_file(before.st_size)
376
+ chunks: list[bytes] = []
377
+ remaining = before.st_size
378
+ while remaining:
379
+ budget.check()
380
+ chunk = os.read(descriptor, min(64 * 1024, remaining))
381
+ if not chunk:
382
+ raise ProviderCommandDiscoveryError(
383
+ "an OpenCode skill changed while being read"
384
+ )
385
+ chunks.append(chunk)
386
+ remaining -= len(chunk)
387
+ data = b"".join(chunks)
388
+ after = os.fstat(descriptor)
389
+ if (
390
+ before.st_dev,
391
+ before.st_ino,
392
+ before.st_size,
393
+ before.st_mtime_ns,
394
+ ) != (
395
+ after.st_dev,
396
+ after.st_ino,
397
+ after.st_size,
398
+ after.st_mtime_ns,
399
+ ):
400
+ raise ProviderCommandDiscoveryError(
401
+ "an OpenCode skill changed while being read"
402
+ )
403
+ return data
404
+ finally:
405
+ os.close(descriptor)
406
+
407
+
408
+ def _bounded_skill_files(
409
+ root: Path,
410
+ budget: _OpenCodeSkillScanBudget,
411
+ ) -> list[Path]:
412
+ if _path_has_symlink(root):
413
+ return []
414
+ try:
415
+ root_stat = root.lstat()
416
+ except FileNotFoundError:
417
+ return []
418
+ except OSError as exc:
419
+ raise ProviderCommandDiscoveryError(
420
+ "an OpenCode skill root could not be inspected"
421
+ ) from exc
422
+ if not stat.S_ISDIR(root_stat.st_mode):
423
+ return []
424
+
425
+ matches: list[Path] = []
426
+ pending: list[tuple[Path, int]] = [(root, 0)]
427
+ while pending:
428
+ directory, depth = pending.pop()
429
+ budget.count_directory()
430
+ try:
431
+ with os.scandir(directory) as iterator:
432
+ entries = []
433
+ for entry in iterator:
434
+ budget.count_entry()
435
+ entries.append(entry)
436
+ entries.sort(key=lambda item: item.name)
437
+ except OSError as exc:
438
+ raise ProviderCommandDiscoveryError(
439
+ "an OpenCode skill directory could not be read"
440
+ ) from exc
441
+ child_dirs: list[Path] = []
442
+ for entry in entries:
443
+ budget.check()
444
+ try:
445
+ entry_stat = entry.stat(follow_symlinks=False)
446
+ except OSError as exc:
447
+ raise ProviderCommandDiscoveryError(
448
+ "an OpenCode skill entry could not be inspected"
449
+ ) from exc
450
+ if stat.S_ISLNK(entry_stat.st_mode):
451
+ continue
452
+ candidate = directory / entry.name
453
+ if stat.S_ISDIR(entry_stat.st_mode):
454
+ if depth < MAX_OPENCODE_SKILL_SCAN_DEPTH:
455
+ child_dirs.append(candidate)
456
+ continue
457
+ if stat.S_ISREG(entry_stat.st_mode) and entry.name == "SKILL.md":
458
+ matches.append(candidate)
459
+ # Stack reversal preserves lexical traversal while remaining iterative.
460
+ pending.extend((child, depth + 1) for child in reversed(child_dirs))
461
+ return matches
462
+
463
+
464
+ def _opencode_project_ancestors(cwd: Path) -> list[Path]:
465
+ """Walk nearest-first through the git worktree, or only cwd outside git."""
466
+
467
+ ancestors: list[Path] = []
468
+ current = cwd
469
+ worktree_index: int | None = None
470
+ for _ in range(MAX_OPENCODE_SKILL_ANCESTORS):
471
+ ancestors.append(current)
472
+ git_marker = current / ".git"
473
+ try:
474
+ marker_mode = git_marker.lstat().st_mode
475
+ except FileNotFoundError:
476
+ marker_mode = 0
477
+ except OSError as exc:
478
+ raise ProviderCommandDiscoveryError(
479
+ "the OpenCode worktree boundary could not be inspected"
480
+ ) from exc
481
+ if marker_mode and not stat.S_ISLNK(marker_mode):
482
+ worktree_index = len(ancestors) - 1
483
+ break
484
+ parent = current.parent
485
+ if parent == current:
486
+ break
487
+ current = parent
488
+ else:
489
+ raise ProviderCommandDiscoveryError(
490
+ "the OpenCode worktree exceeds the ancestor limit"
491
+ )
492
+ return ancestors[: worktree_index + 1] if worktree_index is not None else [cwd]
493
+
494
+
495
+ def opencode_provider_skill_inventory(
496
+ *,
497
+ cwd: str,
498
+ home: str,
499
+ xdg_config_home: str | None,
500
+ selector_secret: str,
501
+ binding_context: str,
502
+ limit: int = MAX_PROVIDER_COMMANDS,
503
+ timeout_seconds: float = OPENCODE_SKILL_SCAN_TIMEOUT_SECONDS,
504
+ ) -> ProviderCommandInventory:
505
+ """Index the documented default local OpenCode skill roots only.
506
+
507
+ This deliberately does not evaluate OpenCode configuration or start its
508
+ CLI. Configured paths/URLs, plugins, commands, agents, MCP prompts, the
509
+ undocumented ``~/.opencode`` compatibility root, and built-in skills are
510
+ outside this side-effect-free v1 contract.
511
+ """
512
+
513
+ try:
514
+ canonical_cwd = Path(
515
+ os.path.abspath(os.path.normpath(os.path.expanduser(cwd)))
516
+ ).resolve(strict=True)
517
+ canonical_home = Path(
518
+ os.path.abspath(os.path.normpath(os.path.expanduser(home)))
519
+ ).resolve(strict=True)
520
+ except (OSError, RuntimeError, ValueError) as exc:
521
+ raise ProviderCommandDiscoveryError(
522
+ "OpenCode skill discovery boundaries are unavailable"
523
+ ) from exc
524
+ if not canonical_cwd.is_dir() or not canonical_home.is_dir():
525
+ raise ProviderCommandDiscoveryError(
526
+ "OpenCode skill discovery boundaries are not directories"
527
+ )
528
+ if xdg_config_home:
529
+ expanded_xdg = Path(os.path.expanduser(xdg_config_home))
530
+ config_home = (
531
+ expanded_xdg
532
+ if expanded_xdg.is_absolute()
533
+ else canonical_home / ".config"
534
+ )
535
+ else:
536
+ config_home = canonical_home / ".config"
537
+ try:
538
+ if config_home.exists():
539
+ config_home = config_home.resolve(strict=True)
540
+ except (OSError, RuntimeError) as exc:
541
+ raise ProviderCommandDiscoveryError(
542
+ "the OpenCode configuration boundary could not be resolved"
543
+ ) from exc
544
+
545
+ project_roots: list[tuple[Path, str]] = []
546
+ for ancestor in _opencode_project_ancestors(canonical_cwd):
547
+ project_roots.extend((
548
+ (ancestor / ".opencode" / "skills", "project"),
549
+ (ancestor / ".claude" / "skills", "project"),
550
+ (ancestor / ".agents" / "skills", "project"),
551
+ ))
552
+ roots = [
553
+ *project_roots,
554
+ (config_home / "opencode" / "skills", "user"),
555
+ (canonical_home / ".claude" / "skills", "user"),
556
+ (canonical_home / ".agents" / "skills", "user"),
557
+ ]
558
+ if len(roots) > MAX_OPENCODE_SKILL_ROOTS:
559
+ raise ProviderCommandDiscoveryError(
560
+ "OpenCode skill discovery exceeded its root limit"
561
+ )
562
+
563
+ budget = _OpenCodeSkillScanBudget(
564
+ deadline=time.monotonic() + max(0.01, float(timeout_seconds))
565
+ )
566
+ discovered: dict[str, list[tuple[Path, bytes, str, str]]] = {}
567
+ seen_paths: set[str] = set()
568
+ for root, scope in roots:
569
+ budget.check()
570
+ for path in _bounded_skill_files(root, budget):
571
+ canonical_path = os.path.abspath(os.path.normpath(str(path)))
572
+ if len(canonical_path) > MAX_PROVIDER_COMMAND_PATH_CHARS:
573
+ raise ProviderCommandDiscoveryError(
574
+ "an OpenCode skill path exceeds the path limit"
575
+ )
576
+ if canonical_path in seen_paths:
577
+ continue
578
+ seen_paths.add(canonical_path)
579
+ data = _read_regular_file_without_symlinks(path, budget)
580
+ parsed = _opencode_skill_frontmatter(data)
581
+ if parsed is None:
582
+ continue
583
+ name, description, _content = parsed
584
+ if path.parent.name != name:
585
+ continue
586
+ discovered.setdefault(name, []).append((path, data, scope, description))
587
+
588
+ bounded_limit = max(1, min(int(limit), MAX_PROVIDER_COMMANDS))
589
+ unique = [
590
+ (name, entries[0])
591
+ for name, entries in sorted(discovered.items())
592
+ if len(entries) == 1
593
+ ]
594
+ if len(unique) > bounded_limit:
595
+ raise ProviderCommandDiscoveryError(
596
+ "OpenCode skill discovery exceeded its inventory limit"
597
+ )
598
+
599
+ records: list[ProviderCommandRecord] = []
600
+ for name, (path, data, scope, description) in unique:
601
+ canonical_path = os.path.abspath(os.path.normpath(str(path)))
602
+ digest = hashlib.sha256(data).hexdigest()
603
+ native_identity = json.dumps(
604
+ [canonical_path, digest],
605
+ ensure_ascii=False,
606
+ separators=(",", ":"),
607
+ )
608
+ command_id = _opaque_command_id(
609
+ selector_secret,
610
+ binding_context,
611
+ "opencode",
612
+ str(canonical_cwd),
613
+ name,
614
+ native_identity,
615
+ )
616
+ safe_description = sanitize_provider_command_text(
617
+ description,
618
+ MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS,
619
+ )
620
+ records.append(ProviderCommandRecord(
621
+ public={
622
+ "id": command_id,
623
+ "name": name,
624
+ "label": name,
625
+ "description": safe_description,
626
+ "scope": scope,
627
+ "source": "opencode",
628
+ "kind": "skill",
629
+ "invocation": f"/{name}",
630
+ },
631
+ native={
632
+ "name": name,
633
+ "path": canonical_path,
634
+ "directory": os.path.dirname(canonical_path),
635
+ "content_sha256": digest,
636
+ },
637
+ ))
638
+
639
+ return _inventory(
640
+ selector_secret,
641
+ binding_context,
642
+ "opencode",
643
+ str(canonical_cwd),
644
+ records,
645
+ truncated=False,
646
+ )
647
+
648
+
649
+ def validate_opencode_provider_skill_record(
650
+ record: ProviderCommandRecord,
651
+ ) -> tuple[str, str, str]:
652
+ """Revalidate one private skill identity immediately before launch."""
653
+
654
+ name = str(record.native.get("name") or "")
655
+ raw_path = str(record.native.get("path") or "")
656
+ expected_directory = str(record.native.get("directory") or "")
657
+ expected_digest = str(record.native.get("content_sha256") or "")
658
+ if (
659
+ record.public.get("source") != "opencode"
660
+ or record.public.get("kind") != "skill"
661
+ or record.public.get("name") != name
662
+ or record.public.get("invocation") != f"/{name}"
663
+ or _OPENCODE_SKILL_NAME_RE.fullmatch(name) is None
664
+ or not raw_path
665
+ or not os.path.isabs(raw_path)
666
+ or len(raw_path) > MAX_PROVIDER_COMMAND_PATH_CHARS
667
+ or not expected_directory
668
+ or not hmac.compare_digest(
669
+ expected_digest,
670
+ expected_digest.lower(),
671
+ )
672
+ or re.fullmatch(r"[0-9a-f]{64}", expected_digest) is None
673
+ ):
674
+ raise ProviderCommandDiscoveryError(
675
+ "the selected OpenCode skill identity is invalid"
676
+ )
677
+ path = Path(os.path.abspath(os.path.normpath(raw_path)))
678
+ if _path_has_symlink(path):
679
+ raise ProviderCommandDiscoveryError(
680
+ "the selected OpenCode skill path is no longer safe"
681
+ )
682
+ budget = _OpenCodeSkillScanBudget(
683
+ deadline=time.monotonic() + OPENCODE_SKILL_SCAN_TIMEOUT_SECONDS
684
+ )
685
+ data = _read_regular_file_without_symlinks(path, budget)
686
+ parsed = _opencode_skill_frontmatter(data)
687
+ canonical_directory = os.path.dirname(str(path))
688
+ if (
689
+ parsed is None
690
+ or parsed[0] != name
691
+ or path.parent.name != name
692
+ or canonical_directory != expected_directory
693
+ or not hmac.compare_digest(
694
+ hashlib.sha256(data).hexdigest(),
695
+ expected_digest,
696
+ )
697
+ ):
698
+ raise ProviderCommandDiscoveryError(
699
+ "the selected OpenCode skill changed before launch"
700
+ )
701
+ return name, canonical_directory, parsed[2]
702
+
703
+
125
704
  def _opaque_command_id(
126
705
  selector_secret: str,
127
706
  binding_context: str,