@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.
- package/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- 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,
|