Compare commits

..

18 Commits

Author SHA1 Message Date
didericis-codex 3ceff1ac4f fix: resolve pyright strict errors
lint / lint (push) Successful in 1m50s
test / unit (pull_request) Successful in 39s
test / integration (pull_request) Successful in 25s
2026-06-09 02:15:18 +00:00
didericis a397d37bbe fix(egress): ignore stripped auth header in DLP scan
lint / lint (push) Failing after 1m32s
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 17s
2026-06-08 15:43:46 -04:00
didericis-claude 37a780acf6 refactor: make AgentProvisionPlan the source of truth for instance_name, prompt_file, image, dockerfile, guest_home
lint / lint (push) Failing after 1m47s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 24s
Drop the parallel fields passed through prepare() → _resolve_plan and
read everything from agent_provision instead. The provider plugin now
declares its own guest_home (so the backend stops hardcoding
"/home/node") and the wrapper that builds the provision plan accepts
instance_name and prompt_file, which providers store on the plan.

DockerBottlePlan and SmolmachinesBottlePlan expose container_name /
machine_name, image / agent_image, dockerfile_path /
agent_dockerfile_path, and prompt_file as properties that delegate to
agent_provision so existing call sites keep working unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:23:19 +00:00
didericis-claude e2514b3885 fix: fall back to provider's bundled Dockerfile when manifest doesn't override
lint / lint (push) Failing after 1m25s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 17s
BottleBackend.prepare was calling resolve_manifest_dockerfile("", spec)
for every bottle where the manifest did not set agent_provider.dockerfile.
That resolves an empty string against user_cwd, returning the cwd
itself — which docker then tried to read as a Dockerfile, giving
"is a directory" errors during image build.

When the manifest doesn't override, use the provider plugin's bundled
Dockerfile path (next to its agent_provider.py module) — mirroring
the pre-refactor behavior.
2026-06-08 17:47:34 +00:00
didericis-claude a002d32779 fix: thread slug + resolved_env from prepare to each backend's _resolve_plan
lint / lint (push) Failing after 1m24s
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 17s
BottleBackend.prepare computed slug and resolved_env but never passed
them to _resolve_plan. The concrete docker/smolmachines _resolve_plan
methods still had the old (spec, *, stage_dir) signature too, so
prepare's kwargs blew up with "unexpected keyword argument
'instance_name'" the moment cli.py start was invoked.

Update the abstract _resolve_plan signature and both backend
implementations to accept the full kwarg set prepare passes, and
forward to resolve_plan.resolve_plan() with everything.
2026-06-08 17:41:16 +00:00
didericis-claude e8d8cf8a64 chore: comment out workspace + capability_apply, fix circular imports
lint / lint (push) Failing after 1m34s
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 19s
The recent refactor partially removed workspace planning and
capability-apply logic. This commit finishes the cleanup so the
test suite imports cleanly:

- Comment out workspace_plan field/property on BottlePlan and the
  provision_workspace dispatch.
- Comment out workspace usages in docker.util (build_image_with_cwd),
  smolmachines.provision.workspace, agent_provider.provision_git,
  smolmachines.backend.
- Comment out capability_apply imports in cli.start and cli.supervise;
  add a local CapabilityApplyError placeholder so the supervise CLI
  module still imports.
- Break the bottle_state → backend.docker → backend circular import
  by lazy-loading docker_mod inside bottle_identity, and by moving the
  resolve_common import inside BottleBackend.prepare.
- Delete tests for workspace and capability_apply (unit + integration).
- Update test fixtures to drop removed kwargs (container_name_pinned,
  derived_image, env_file, workspace_plan, agent_image_ref) from
  DockerBottlePlan / SmolmachinesBottlePlan constructors.
- Delete the obsolete test_smolmachines_prepare.py (tested the old
  resolve_plan signature; the shared prepare flow now lives in
  BottleBackend.prepare).
- Adjust test_supervise.py for the new Supervise.prepare signature
  (dockerfile_content arg removed).

925 → 897 tests, all passing.
2026-06-08 17:36:51 +00:00
didericis 9470b8f955 chore: SAVEPOINT
lint / lint (push) Failing after 1m52s
test / unit (pull_request) Failing after 37s
test / integration (pull_request) Failing after 22s
2026-06-08 13:13:57 -04:00
didericis 249169eca1 Remove unused port declaration 2026-06-08 11:46:00 -04:00
didericis-claude dede230c4a refactor: move guest_home onto AgentProvisionPlan as source of truth
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Failing after 16s
guest_home is now a field on AgentProvisionPlan (set by each provider's
provision_plan() method). BottlePlan.guest_home becomes a read-only
property delegating to agent_provision.guest_home so existing callers
(provision_git, provision_skills, provision_prompt) are unchanged.

Both resolve_plan.py files drop guest_home from the plan constructor
call; the local variable still exists as an intermediary for the
workspace_plan call that precedes agent_provision_plan.
2026-06-08 14:58:31 +00:00
didericis-claude c39d5dc63f refactor: extract shared resolve_plan helpers into backend/resolve_common.py
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Failing after 15s
Both docker and smolmachines resolve_plan.py duplicated: slug minting,
metadata writing, agent state dir setup, git gate / egress / supervise
preparation, env_vars merge, and manifest dockerfile path resolution.

These are now consolidated in bot_bottle/backend/resolve_common.py.
Each backend's resolve_plan retains only its own logic (container name
resolution + env-file for docker; subnet allocation + guest_env build
for smolmachines).
2026-06-08 14:46:04 +00:00
didericis-claude 4359bd6099 refactor: move bottle_state.py to top-level bot_bottle package
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Failing after 16s
Both docker and smolmachines backends use bottle state helpers.
Moving to bot_bottle/ makes the sharing explicit and removes the
cross-backend dependency (smolmachines importing from ..docker).

All callers updated: docker backend, smolmachines backend, cli
modules, and tests.
2026-06-08 14:38:24 +00:00
didericis-claude f95eabeb86 refactor: rename prepare.py → resolve_plan.py in both backends
lint / lint (push) Failing after 1m45s
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 47s
2026-06-08 14:12:48 +00:00
didericis-claude b872985a65 refactor: prefix all manifest data classes with Manifest
lint / lint (push) Failing after 1m29s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 41s
Avoids name collisions with same-named runtime/plugin classes
(e.g. manifest AgentProvider vs plugin AgentProvider ABC,
manifest EgressRoute vs runtime EgressRoute). Renamed:

  AgentProvider        → ManifestAgentProvider   (manifest_agent.py)
  Agent                → ManifestAgent            (manifest_agent.py)
  EgressRoute          → ManifestEgressRoute      (manifest_egress.py)
  PathMatch            → ManifestPathMatch        (manifest_egress.py)
  HeaderMatch          → ManifestHeaderMatch      (manifest_egress.py)
  MatchEntry           → ManifestMatchEntry       (manifest_egress.py)
  EgressConfig         → ManifestEgressConfig     (manifest_egress.py)
  Bottle               → ManifestBottle           (manifest.py)
  ProvisionedKeyConfig → ManifestProvisionedKeyConfig (manifest_git.py)
  GitEntry             → ManifestGitEntry         (manifest_git.py)
  GitUser              → ManifestGitUser          (manifest_git.py)
2026-06-08 06:42:06 +00:00
didericis-claude a4e12855df refactor: set image/dockerfile from provider default first, override after
lint / lint (push) Failing after 1m36s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 44s
Since every provider always has a dockerfile, establish the default
image and dockerfile_path from the provider up front and override for
per-bottle or manifest-specified cases. Removes the image_default
intermediate variable and the trailing else branch.
2026-06-08 06:17:48 +00:00
didericis-claude e0ecb7ceb1 refactor: AgentProvider.dockerfile always returns Path, never None
lint / lint (push) Failing after 1m50s
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 59s
The convention is that every provider declares a Dockerfile location;
callers that care whether the file actually exists check .is_file().
Drops all `is not None` guards on the property result.
2026-06-08 06:06:51 +00:00
didericis-claude 41590ede1f refactor: remove BOT_BOTTLE_IMAGE env override
lint / lint (push) Failing after 1m51s
test / unit (pull_request) Successful in 39s
test / integration (pull_request) Successful in 1m0s
Unused in tests, docs, or examples. Can be added back if/when merited.
2026-06-08 04:05:29 +00:00
didericis-claude 963a178b20 refactor: replace runtime.dockerfile with AgentProvider.dockerfile property
lint / lint (push) Failing after 1m37s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 57s
Drop the `dockerfile` field from `AgentProviderRuntime` and replace it
with a convention-based `dockerfile` property on `AgentProvider`: the
base class looks for a `Dockerfile` file next to the provider's own
`agent_provider.py` module (via `inspect.getfile`), returning its path
or None. Built-in providers inherit the default automatically; custom
user providers work the same way by dropping a Dockerfile next to their
plugin file; any provider needing a non-standard path can override.

All callers (`docker/prepare.py`, `smolmachines/prepare.py`,
`capability_apply.py`) now resolve the provider object once and call
`.dockerfile` directly instead of reading `runtime.dockerfile`.
2026-06-08 03:56:04 +00:00
didericis-claude e9adcdd91d refactor: move agent Dockerfiles into their contrib directories
lint / lint (push) Successful in 1m27s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 43s
Dockerfile.claude and Dockerfile.codex move from the repo root into
bot_bottle/contrib/claude/Dockerfile and bot_bottle/contrib/codex/Dockerfile
respectively, so all per-provider assets live alongside the provider code.

Closes #215
2026-06-08 03:38:19 +00:00
+36 -30
View File
@@ -69,6 +69,12 @@ class YamlSubsetError(ValueError):
egress sidecar's addon) handle it as a normal exception.""" egress sidecar's addon) handle it as a normal exception."""
def die(msg: str) -> None:
"""Module-local helper so the parser body reads cleanly. Just
raises YamlSubsetError — the `bot-bottle: error: ` prefix
is added by the boundary `die` in `bot_bottle.log`."""
raise YamlSubsetError(msg)
# --- Tokenizer / line preprocessing ---------------------------------------- # --- Tokenizer / line preprocessing ----------------------------------------
@@ -113,7 +119,7 @@ def _tokenize(text: str) -> list[_Line]:
# editors render them differently and the spec says spaces. # editors render them differently and the spec says spaces.
leading = len(raw) - len(raw.lstrip(" \t")) leading = len(raw) - len(raw.lstrip(" \t"))
if "\t" in raw[:leading]: if "\t" in raw[:leading]:
raise YamlSubsetError(f"yaml-subset: tab character in indent on line {n}") die(f"yaml-subset: tab character in indent on line {n}")
stripped = raw.strip() stripped = raw.strip()
if not stripped: if not stripped:
continue continue
@@ -163,14 +169,14 @@ def _parse_scalar(s: str, lineno: int) -> object:
s.startswith("'") and s.endswith("'") s.startswith("'") and s.endswith("'")
): ):
if len(s) < 2: if len(s) < 2:
raise YamlSubsetError(f"yaml-subset: unterminated quoted string on line {lineno}") die(f"yaml-subset: unterminated quoted string on line {lineno}")
body = s[1:-1] body = s[1:-1]
if s.startswith('"'): if s.startswith('"'):
# JSON-style escapes for double quotes. # JSON-style escapes for double quotes.
try: try:
return body.encode("utf-8").decode("unicode_escape") return body.encode("utf-8").decode("unicode_escape")
except UnicodeDecodeError as e: except UnicodeDecodeError as e:
raise YamlSubsetError(f"yaml-subset: bad escape on line {lineno}: {e}") die(f"yaml-subset: bad escape on line {lineno}: {e}")
else: else:
# Single quotes: only '' → ' (standard YAML); no other escapes. # Single quotes: only '' → ' (standard YAML); no other escapes.
return body.replace("''", "'") return body.replace("''", "'")
@@ -180,7 +186,7 @@ def _parse_scalar(s: str, lineno: int) -> object:
if s in _RESERVED_BOOL_LIKE: if s in _RESERVED_BOOL_LIKE:
if s in ("true", "false"): if s in ("true", "false"):
return s == "true" return s == "true"
raise YamlSubsetError( die(
f"yaml-subset: bare {s!r} on line {lineno} is ambiguous " f"yaml-subset: bare {s!r} on line {lineno} is ambiguous "
f"(use literal `true` / `false`, or quote it as a string)" f"(use literal `true` / `false`, or quote it as a string)"
) )
@@ -197,22 +203,22 @@ def _parse_scalar(s: str, lineno: int) -> object:
# Look-alikes that we reject to keep the user in control. # Look-alikes that we reject to keep the user in control.
if _DATE_RX.match(s): if _DATE_RX.match(s):
raise YamlSubsetError( die(
f"yaml-subset: bare {s!r} on line {lineno} looks like a " f"yaml-subset: bare {s!r} on line {lineno} looks like a "
f"date — quote it as a string or use an explicit int" f"date — quote it as a string or use an explicit int"
) )
if _OCTAL_RX.match(s): if _OCTAL_RX.match(s):
raise YamlSubsetError( die(
f"yaml-subset: bare {s!r} on line {lineno} looks like an " f"yaml-subset: bare {s!r} on line {lineno} looks like an "
f"octal/0-prefixed integer — quote it as a string" f"octal/0-prefixed integer — quote it as a string"
) )
if _HEX_RX.match(s): if _HEX_RX.match(s):
raise YamlSubsetError( die(
f"yaml-subset: bare {s!r} on line {lineno} looks like a " f"yaml-subset: bare {s!r} on line {lineno} looks like a "
f"hex integer — quote it as a string" f"hex integer — quote it as a string"
) )
if _FLOAT_RX.match(s): if _FLOAT_RX.match(s):
raise YamlSubsetError( die(
f"yaml-subset: floats not supported (line {lineno}, " f"yaml-subset: floats not supported (line {lineno}, "
f"value {s!r}); use an int or quote as a string" f"value {s!r}); use an int or quote as a string"
) )
@@ -235,7 +241,7 @@ def _parse_inline(s: str, lineno: int) -> object:
s = s.strip() s = s.strip()
if s.startswith("["): if s.startswith("["):
if not s.endswith("]"): if not s.endswith("]"):
raise YamlSubsetError(f"yaml-subset: unterminated `[` on line {lineno}") die(f"yaml-subset: unterminated `[` on line {lineno}")
body = s[1:-1].strip() body = s[1:-1].strip()
if not body: if not body:
return [] return []
@@ -246,21 +252,21 @@ def _parse_inline(s: str, lineno: int) -> object:
return items return items
if s.startswith("{"): if s.startswith("{"):
if not s.endswith("}"): if not s.endswith("}"):
raise YamlSubsetError(f"yaml-subset: unterminated `{{` on line {lineno}") die(f"yaml-subset: unterminated `{{` on line {lineno}")
body = s[1:-1].strip() body = s[1:-1].strip()
if not body: if not body:
return {} return {}
out: dict[str, object] = {} out: dict[str, object] = {}
for raw in _split_flow(body, lineno, "dict"): for raw in _split_flow(body, lineno, "dict"):
if ":" not in raw: if ":" not in raw:
raise YamlSubsetError( die(
f"yaml-subset: inline dict entry on line {lineno} " f"yaml-subset: inline dict entry on line {lineno} "
f"missing `:` ({raw!r})" f"missing `:` ({raw!r})"
) )
k, _, v = raw.partition(":") k, _, v = raw.partition(":")
k = k.strip() k = k.strip()
if not _BARE_RX.match(k): if not _BARE_RX.match(k):
raise YamlSubsetError( die(
f"yaml-subset: inline dict key on line {lineno} " f"yaml-subset: inline dict key on line {lineno} "
f"must be a bare identifier ({k!r})" f"must be a bare identifier ({k!r})"
) )
@@ -290,7 +296,7 @@ def _split_flow(body: str, lineno: int, kind: str) -> list[str]:
elif ch in "]}": elif ch in "]}":
depth_b -= 1 depth_b -= 1
if depth_b > 0: if depth_b > 0:
raise YamlSubsetError( die(
f"yaml-subset: nested flow {kind} on line " f"yaml-subset: nested flow {kind} on line "
f"{lineno} (only one level of flow allowed)" f"{lineno} (only one level of flow allowed)"
) )
@@ -324,7 +330,7 @@ def _split_key_value(content: str, lineno: int) -> tuple[str, str]:
# ambiguous with URLs etc.). # ambiguous with URLs etc.).
if i + 1 >= len(content) or content[i + 1] in (" ", "\t"): if i + 1 >= len(content) or content[i + 1] in (" ", "\t"):
return content[:i].strip(), content[i + 1:].lstrip() return content[:i].strip(), content[i + 1:].lstrip()
raise YamlSubsetError(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}") die(f"yaml-subset: line {lineno} missing `: ` separator: {content!r}")
return "", "" # unreachable, but needed for type checker return "", "" # unreachable, but needed for type checker
@@ -335,15 +341,15 @@ def _parse_block(
to live at `base_indent`. Returns (value, new_idx) where to live at `base_indent`. Returns (value, new_idx) where
`new_idx` is the index of the first unconsumed line.""" `new_idx` is the index of the first unconsumed line."""
if idx >= len(lines): if idx >= len(lines):
raise YamlSubsetError("yaml-subset: unexpected end of document") die("yaml-subset: unexpected end of document")
first = lines[idx] first = lines[idx]
if first.indent < base_indent: if first.indent < base_indent:
raise YamlSubsetError( die(
f"yaml-subset: line {first.lineno} indented less than " f"yaml-subset: line {first.lineno} indented less than "
f"expected (got {first.indent}, expected >= {base_indent})" f"expected (got {first.indent}, expected >= {base_indent})"
) )
if first.indent > base_indent: if first.indent > base_indent:
raise YamlSubsetError( die(
f"yaml-subset: line {first.lineno} indented more than " f"yaml-subset: line {first.lineno} indented more than "
f"expected (got {first.indent}, expected {base_indent})" f"expected (got {first.indent}, expected {base_indent})"
) )
@@ -360,18 +366,18 @@ def _parse_block_mapping(
while idx < len(lines) and lines[idx].indent == base_indent: while idx < len(lines) and lines[idx].indent == base_indent:
line = lines[idx] line = lines[idx]
if line.content.startswith("- "): if line.content.startswith("- "):
raise YamlSubsetError( die(
f"yaml-subset: line {line.lineno} unexpected list " f"yaml-subset: line {line.lineno} unexpected list "
f"item at mapping indent (got `-`, expected `key:`)" f"item at mapping indent (got `-`, expected `key:`)"
) )
key, value_text = _split_key_value(line.content, line.lineno) key, value_text = _split_key_value(line.content, line.lineno)
if not _BARE_RX.match(key): if not _BARE_RX.match(key):
raise YamlSubsetError( die(
f"yaml-subset: line {line.lineno} key {key!r} is not " f"yaml-subset: line {line.lineno} key {key!r} is not "
f"a bare identifier" f"a bare identifier"
) )
if key in out: if key in out:
raise YamlSubsetError( die(
f"yaml-subset: line {line.lineno} duplicate key {key!r}" f"yaml-subset: line {line.lineno} duplicate key {key!r}"
) )
if value_text: if value_text:
@@ -411,7 +417,7 @@ def _parse_block_list(
content_col = base_indent + 2 content_col = base_indent + 2
first_key, first_value_text = _split_key_value(rest, line.lineno) first_key, first_value_text = _split_key_value(rest, line.lineno)
if not _BARE_RX.match(first_key): if not _BARE_RX.match(first_key):
raise YamlSubsetError( die(
f"yaml-subset: line {line.lineno} key {first_key!r} " f"yaml-subset: line {line.lineno} key {first_key!r} "
f"is not a bare identifier" f"is not a bare identifier"
) )
@@ -434,12 +440,12 @@ def _parse_block_list(
break # next list item, not a sibling key break # next list item, not a sibling key
k, v_text = _split_key_value(ln.content, ln.lineno) k, v_text = _split_key_value(ln.content, ln.lineno)
if not _BARE_RX.match(k): if not _BARE_RX.match(k):
raise YamlSubsetError( die(
f"yaml-subset: line {ln.lineno} key {k!r} is " f"yaml-subset: line {ln.lineno} key {k!r} is "
f"not a bare identifier" f"not a bare identifier"
) )
if k in item: if k in item:
raise YamlSubsetError(f"yaml-subset: line {ln.lineno} duplicate key {k!r}") die(f"yaml-subset: line {ln.lineno} duplicate key {k!r}")
if v_text: if v_text:
item[k] = _parse_inline(v_text, ln.lineno) item[k] = _parse_inline(v_text, ln.lineno)
idx += 1 idx += 1
@@ -495,7 +501,7 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
for n, raw in enumerate(text.splitlines(), start=1): for n, raw in enumerate(text.splitlines(), start=1):
s = raw.strip() s = raw.strip()
if s.startswith("|") or s.startswith(">") or s.startswith("- |") or s.startswith("- >"): if s.startswith("|") or s.startswith(">") or s.startswith("- |") or s.startswith("- >"):
raise YamlSubsetError( die(
f"yaml-subset: line {n} uses a multi-line block " f"yaml-subset: line {n} uses a multi-line block "
f"scalar (`|` / `>`) — not supported. Use a quoted " f"scalar (`|` / `>`) — not supported. Use a quoted "
f"single-line string instead." f"single-line string instead."
@@ -505,12 +511,12 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
# not when it's inside a quoted string. Cheap check: any # not when it's inside a quoted string. Cheap check: any
# bare `&foo:` / `*foo` at the start of a value position. # bare `&foo:` / `*foo` at the start of a value position.
if re.search(r"(^|\s)[&*][A-Za-z0-9_]+", s): if re.search(r"(^|\s)[&*][A-Za-z0-9_]+", s):
raise YamlSubsetError( die(
f"yaml-subset: line {n} uses anchors / aliases " f"yaml-subset: line {n} uses anchors / aliases "
f"(`&` / `*`) — not supported." f"(`&` / `*`) — not supported."
) )
if "!!" in s and not (s.count("'") % 2 or s.count('"') % 2): if "!!" in s and not (s.count("'") % 2 or s.count('"') % 2):
raise YamlSubsetError( die(
f"yaml-subset: line {n} uses a YAML tag (`!!`) — not " f"yaml-subset: line {n} uses a YAML tag (`!!`) — not "
f"supported." f"supported."
) )
@@ -520,18 +526,18 @@ def parse_yaml_subset(text: str) -> dict[str, object]:
return {} return {}
base_indent = lines[0].indent base_indent = lines[0].indent
if base_indent != 0: if base_indent != 0:
raise YamlSubsetError( die(
f"yaml-subset: top-level content must start in column 0 " f"yaml-subset: top-level content must start in column 0 "
f"(got column {base_indent} on line {lines[0].lineno})" f"(got column {base_indent} on line {lines[0].lineno})"
) )
value, consumed = _parse_block(lines, 0, 0) value, consumed = _parse_block(lines, 0, 0)
if consumed < len(lines): if consumed < len(lines):
raise YamlSubsetError( die(
f"yaml-subset: trailing content starting on line " f"yaml-subset: trailing content starting on line "
f"{lines[consumed].lineno}" f"{lines[consumed].lineno}"
) )
if not isinstance(value, dict): if not isinstance(value, dict):
raise YamlSubsetError("yaml-subset: top-level value must be a mapping") die("yaml-subset: top-level value must be a mapping")
return cast(dict[str, object], value) return cast(dict[str, object], value)
@@ -570,7 +576,7 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
fm_end_lineno = line_idx fm_end_lineno = line_idx
break break
if body_start < 0: if body_start < 0:
raise YamlSubsetError("frontmatter: opening `---` has no matching closing `---`") die("frontmatter: opening `---` has no matching closing `---`")
fm_text = text[line_starts[1]:line_starts[fm_end_lineno]] if fm_end_lineno > 1 else "" fm_text = text[line_starts[1]:line_starts[fm_end_lineno]] if fm_end_lineno > 1 else ""
fm = parse_yaml_subset(fm_text) fm = parse_yaml_subset(fm_text)