Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2081839c9 | |||
| 23015f7fd8 | |||
| 94eca35b4f | |||
| f787764364 | |||
| a256e5762a |
@@ -217,7 +217,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -227,9 +227,13 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -193,9 +193,13 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
"""Copy the prompt file into the guest, fix ownership/mode.
|
"""Copy the prompt file into the guest, fix ownership/mode.
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ class PiAgentProvider(AgentProvider):
|
|||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
bottle.exec(f"mkdir -p {skills_dir}", user="root")
|
bottle.exec(f"mkdir -p {shlex.quote(skills_dir)}", user="root")
|
||||||
for name in agent.skills:
|
for name in agent.skills:
|
||||||
src = host_skill_dir(name)
|
src = host_skill_dir(name)
|
||||||
if not os.path.isdir(src):
|
if not os.path.isdir(src):
|
||||||
@@ -248,9 +248,13 @@ class PiAgentProvider(AgentProvider):
|
|||||||
)
|
)
|
||||||
dst = f"{skills_dir}/{name}"
|
dst = f"{skills_dir}/{name}"
|
||||||
info(f"copying skill {name} into {bottle.name}:{dst}")
|
info(f"copying skill {name} into {bottle.name}:{dst}")
|
||||||
bottle.exec(f"rm -rf {dst} && mkdir -p {dst}", user="root")
|
# Defense in depth: skill names are validated kebab-case at
|
||||||
|
# manifest load, but quote the path so a future unvalidated
|
||||||
|
# field can't inject shell metacharacters here either.
|
||||||
|
dst_q = shlex.quote(dst)
|
||||||
|
bottle.exec(f"rm -rf {dst_q} && mkdir -p {dst_q}", user="root")
|
||||||
bottle.cp_in(f"{src}/.", f"{dst}/")
|
bottle.cp_in(f"{src}/.", f"{dst}/")
|
||||||
bottle.exec(f"chown -R node:node {dst}", user="root")
|
bottle.exec(f"chown -R node:node {dst_q}", user="root")
|
||||||
|
|
||||||
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
def provision_prompt(self, plan: "BottlePlan", bottle: "Bottle") -> str | None:
|
||||||
prompt_path = _prompt_path(plan.guest_home)
|
prompt_path = _prompt_path(plan.guest_home)
|
||||||
|
|||||||
+12
-122
@@ -62,15 +62,25 @@ from dataclasses import dataclass, field, replace
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .log import warn
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_agent import ManifestAgent, ManifestAgentProvider
|
from .manifest_agent import ManifestAgent, ManifestAgentProvider
|
||||||
|
from .manifest_bottle import ManifestBottle
|
||||||
from .manifest_egress import (
|
from .manifest_egress import (
|
||||||
EGRESS_AUTH_SCHEMES,
|
EGRESS_AUTH_SCHEMES,
|
||||||
ManifestEgressConfig,
|
ManifestEgressConfig,
|
||||||
ManifestEgressRoute,
|
ManifestEgressRoute,
|
||||||
)
|
)
|
||||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
|
from .manifest_extends import merge_bottles_runtime, resolve_bottles
|
||||||
from .manifest_schema import BOTTLE_KEYS
|
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
||||||
|
from .manifest_loader import (
|
||||||
|
check_stale_json,
|
||||||
|
load_bottle_chain_from_dir,
|
||||||
|
scan_agent_names,
|
||||||
|
scan_bottle_names,
|
||||||
|
)
|
||||||
|
from .manifest_schema import validate_agent_frontmatter_keys
|
||||||
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
# Re-export everything that callers currently import from this module.
|
# Re-export everything that callers currently import from this module.
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -89,10 +99,6 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _empty_str_dict() -> dict[str, str]:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _section_dict(value: object, label: str) -> dict[str, object]:
|
def _section_dict(value: object, label: str) -> dict[str, object]:
|
||||||
"""Like as_json_object but treats absent/null as an empty section."""
|
"""Like as_json_object but treats absent/null as an empty section."""
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -100,107 +106,6 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
|
|||||||
return as_json_object(value, label)
|
return as_json_object(value, label)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ManifestBottle:
|
|
||||||
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
|
||||||
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
|
||||||
# Per-bottle git identity (issue #86). Empty default — bottles
|
|
||||||
# that don't set `git-gate.user:` in the manifest skip the
|
|
||||||
# `git config --global` step entirely. A bottle can declare a user
|
|
||||||
# identity without any git-gate.repos upstreams, and vice versa.
|
|
||||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
|
||||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
|
||||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
|
||||||
# default, issue #249), the launch step brings up a supervise
|
|
||||||
# sidecar that exposes egress MCP tools to the agent. Set
|
|
||||||
# `supervise: false` to skip the sidecar.
|
|
||||||
supervise: bool = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
|
||||||
d = as_json_object(raw, f"bottle '{name}'")
|
|
||||||
|
|
||||||
if "runtime" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has a 'runtime' field, which is no longer "
|
|
||||||
f"supported. gVisor (runsc) is now auto-detected by the "
|
|
||||||
f"backend; remove the 'runtime' field from the bottle "
|
|
||||||
f"definition."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "ssh" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
|
||||||
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
|
||||||
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
|
|
||||||
f"holds the credential and gitleaks-scans pushes."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "git" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' uses 'git' which has been replaced by "
|
|
||||||
f"'git-gate' (PRD 0047). Move git.user → git-gate.user "
|
|
||||||
f"and git.remotes → git-gate.repos (fields: url, identity, host_key)."
|
|
||||||
)
|
|
||||||
|
|
||||||
if "git_user" in d:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has a 'git_user' field, which has been "
|
|
||||||
f"removed. Move it under 'git-gate.user'."
|
|
||||||
)
|
|
||||||
|
|
||||||
unknown = set(d.keys()) - BOTTLE_KEYS
|
|
||||||
if unknown:
|
|
||||||
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
|
||||||
f"allowed keys are {allowed}."
|
|
||||||
)
|
|
||||||
|
|
||||||
env: dict[str, str] = {}
|
|
||||||
env_raw = d.get("env")
|
|
||||||
if env_raw is not None:
|
|
||||||
env_dict = as_json_object(env_raw, f"bottle '{name}' env")
|
|
||||||
for var, value in env_dict.items():
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ManifestError(
|
|
||||||
f"env entry {var} in bottle '{name}' must be a JSON string "
|
|
||||||
f"(was {type(value).__name__}). Use \"?<message>\" for prompt-at-runtime."
|
|
||||||
)
|
|
||||||
env[var] = value
|
|
||||||
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
|
||||||
git_user = ManifestGitUser()
|
|
||||||
git_raw = d.get("git-gate")
|
|
||||||
if git_raw is not None:
|
|
||||||
git, git_user = parse_git_gate_config(name, git_raw)
|
|
||||||
|
|
||||||
agent_provider = (
|
|
||||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
|
||||||
if "agent_provider" in d
|
|
||||||
else ManifestAgentProvider()
|
|
||||||
)
|
|
||||||
|
|
||||||
egress = (
|
|
||||||
ManifestEgressConfig.from_dict(name, d["egress"])
|
|
||||||
if "egress" in d
|
|
||||||
else ManifestEgressConfig()
|
|
||||||
)
|
|
||||||
|
|
||||||
supervise_raw = d.get("supervise", True)
|
|
||||||
if not isinstance(supervise_raw, bool):
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' supervise must be a boolean "
|
|
||||||
f"(was {type(supervise_raw).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
env=env, agent_provider=agent_provider, git=git,
|
|
||||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_git_user(
|
def _merge_git_user(
|
||||||
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
||||||
) -> ManifestGitUser:
|
) -> ManifestGitUser:
|
||||||
@@ -237,8 +142,6 @@ def _resolve_effective_bottle_eager(
|
|||||||
|
|
||||||
When bottle_names is non-empty they are merged in order. When empty, falls
|
When bottle_names is non-empty they are merged in order. When empty, falls
|
||||||
back to agent.bottle. Raises ManifestError when neither is set."""
|
back to agent.bottle. Raises ManifestError when neither is set."""
|
||||||
from .manifest_extends import merge_bottles_runtime
|
|
||||||
|
|
||||||
if bottle_names:
|
if bottle_names:
|
||||||
resolved: list[ManifestBottle] = []
|
resolved: list[ManifestBottle] = []
|
||||||
for bn in bottle_names:
|
for bn in bottle_names:
|
||||||
@@ -270,9 +173,6 @@ def _resolve_effective_bottle_lazy(
|
|||||||
When bottle_names is non-empty they are resolved from disk and merged in
|
When bottle_names is non-empty they are resolved from disk and merged in
|
||||||
order. When empty, falls back to agent_bottle. Raises ManifestError when
|
order. When empty, falls back to agent_bottle. Raises ManifestError when
|
||||||
neither is set."""
|
neither is set."""
|
||||||
from .manifest_extends import merge_bottles_runtime
|
|
||||||
from .manifest_loader import load_bottle_chain_from_dir
|
|
||||||
|
|
||||||
if bottle_names:
|
if bottle_names:
|
||||||
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
|
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
|
||||||
return merge_bottles_runtime(resolved)
|
return merge_bottles_runtime(resolved)
|
||||||
@@ -358,8 +258,6 @@ class ManifestIndex:
|
|||||||
home_md = home_dir / ".bot-bottle"
|
home_md = home_dir / ".bot-bottle"
|
||||||
cwd_md = cwd_dir / ".bot-bottle"
|
cwd_md = cwd_dir / ".bot-bottle"
|
||||||
|
|
||||||
from .manifest_loader import check_stale_json
|
|
||||||
|
|
||||||
check_stale_json(home_dir, home_md, "$HOME")
|
check_stale_json(home_dir, home_md, "$HOME")
|
||||||
if cwd_dir.resolve() != home_dir.resolve():
|
if cwd_dir.resolve() != home_dir.resolve():
|
||||||
check_stale_json(cwd_dir, cwd_md, "$CWD")
|
check_stale_json(cwd_dir, cwd_md, "$CWD")
|
||||||
@@ -399,7 +297,6 @@ class ManifestIndex:
|
|||||||
files = sorted(stale_bottles.glob("*.md"))
|
files = sorted(stale_bottles.glob("*.md"))
|
||||||
if files:
|
if files:
|
||||||
names = ", ".join(p.name for p in files)
|
names = ", ".join(p.name for p in files)
|
||||||
from .log import warn
|
|
||||||
warn(
|
warn(
|
||||||
f"ignoring bottle file(s) under "
|
f"ignoring bottle file(s) under "
|
||||||
f"{stale_bottles}: {names}. Bottles can only "
|
f"{stale_bottles}: {names}. Bottles can only "
|
||||||
@@ -421,7 +318,6 @@ class ManifestIndex:
|
|||||||
raw_bottles: dict[str, dict[str, object]] = {}
|
raw_bottles: dict[str, dict[str, object]] = {}
|
||||||
for n, b in raw_bottles_obj.items():
|
for n, b in raw_bottles_obj.items():
|
||||||
raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
|
raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
|
||||||
from .manifest_extends import resolve_bottles
|
|
||||||
|
|
||||||
bottles = resolve_bottles(raw_bottles)
|
bottles = resolve_bottles(raw_bottles)
|
||||||
|
|
||||||
@@ -439,7 +335,6 @@ class ManifestIndex:
|
|||||||
filenames without reading their content. In eager mode (from
|
filenames without reading their content. In eager mode (from
|
||||||
from_json_obj) it returns the pre-parsed bottles' names."""
|
from_json_obj) it returns the pre-parsed bottles' names."""
|
||||||
if self.home_md is not None:
|
if self.home_md is not None:
|
||||||
from .manifest_loader import scan_bottle_names
|
|
||||||
return scan_bottle_names(self.home_md / "bottles")
|
return scan_bottle_names(self.home_md / "bottles")
|
||||||
return sorted(self.bottles.keys())
|
return sorted(self.bottles.keys())
|
||||||
|
|
||||||
@@ -451,7 +346,6 @@ class ManifestIndex:
|
|||||||
filenames without reading their content. In eager mode (from
|
filenames without reading their content. In eager mode (from
|
||||||
from_json_obj) it returns the pre-parsed agents' names."""
|
from_json_obj) it returns the pre-parsed agents' names."""
|
||||||
if self.home_md is not None:
|
if self.home_md is not None:
|
||||||
from .manifest_loader import scan_agent_names
|
|
||||||
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
||||||
cwd_names: set[str] = set()
|
cwd_names: set[str] = set()
|
||||||
if self.cwd_md is not None:
|
if self.cwd_md is not None:
|
||||||
@@ -509,10 +403,6 @@ class ManifestIndex:
|
|||||||
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
||||||
its bottle chain from disk for the first time here."""
|
its bottle chain from disk for the first time here."""
|
||||||
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
||||||
from .manifest_loader import scan_agent_names
|
|
||||||
from .manifest_schema import validate_agent_frontmatter_keys
|
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
|
||||||
|
|
||||||
# Locate the agent file; cwd wins over home on name collision.
|
# Locate the agent file; cwd wins over home on name collision.
|
||||||
home_agents = scan_agent_names(self.home_md / "agents")
|
home_agents = scan_agent_names(self.home_md / "agents")
|
||||||
cwd_agents: dict[str, Path] = {}
|
cwd_agents: dict[str, Path] = {}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import cast
|
|||||||
from .agent_provider import PROVIDER_TEMPLATES
|
from .agent_provider import PROVIDER_TEMPLATES
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_git import ManifestGitUser
|
from .manifest_git import ManifestGitUser
|
||||||
from .manifest_schema import AGENT_MODEL_KEYS
|
from .manifest_schema import AGENT_MODEL_KEYS, is_valid_entity_name
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -161,6 +161,16 @@ class ManifestAgent:
|
|||||||
f"agent '{name}' skills[{i}] must be a string "
|
f"agent '{name}' skills[{i}] must be a string "
|
||||||
f"(was {type(skill).__name__})"
|
f"(was {type(skill).__name__})"
|
||||||
)
|
)
|
||||||
|
# Skill names become host/guest path segments and are
|
||||||
|
# interpolated into provisioning shell commands, so they
|
||||||
|
# must fit the same kebab-case convention as bottle/agent
|
||||||
|
# filenames — rejecting anything that could break out of a
|
||||||
|
# path segment or inject shell metacharacters.
|
||||||
|
if not is_valid_entity_name(skill):
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{name}' skills[{i}] {skill!r} is not a valid "
|
||||||
|
f"skill name; must match [a-z][a-z0-9-]*"
|
||||||
|
)
|
||||||
collected.append(skill)
|
collected.append(skill)
|
||||||
skills = tuple(collected)
|
skills = tuple(collected)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""The `ManifestBottle` value type.
|
||||||
|
|
||||||
|
Split out of `manifest.py` so the `extends:`/loader resolvers can import it
|
||||||
|
without a circular dependency: `manifest.py` imports those resolvers, while
|
||||||
|
they only need this value type. Everything here depends on leaf modules
|
||||||
|
(`manifest_util`, `manifest_agent`, `manifest_egress`, `manifest_git`,
|
||||||
|
`manifest_schema`), so this module sits at the bottom of the manifest layer.
|
||||||
|
|
||||||
|
`manifest.py` re-exports `ManifestBottle`, so existing
|
||||||
|
`from .manifest import ManifestBottle` callers are unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
from .manifest_agent import ManifestAgentProvider
|
||||||
|
from .manifest_egress import ManifestEgressConfig
|
||||||
|
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
|
||||||
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
|
|
||||||
|
__all__ = ["ManifestBottle"]
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_str_dict() -> dict[str, str]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ManifestBottle:
|
||||||
|
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
|
||||||
|
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
|
||||||
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
|
# Per-bottle git identity (issue #86). Empty default — bottles
|
||||||
|
# that don't set `git-gate.user:` in the manifest skip the
|
||||||
|
# `git config --global` step entirely. A bottle can declare a user
|
||||||
|
# identity without any git-gate.repos upstreams, and vice versa.
|
||||||
|
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||||
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
|
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||||
|
# default, issue #249), the launch step brings up a supervise
|
||||||
|
# sidecar that exposes egress MCP tools to the agent. Set
|
||||||
|
# `supervise: false` to skip the sidecar.
|
||||||
|
supervise: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||||
|
d = as_json_object(raw, f"bottle '{name}'")
|
||||||
|
|
||||||
|
if "runtime" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has a 'runtime' field, which is no longer "
|
||||||
|
f"supported. gVisor (runsc) is now auto-detected by the "
|
||||||
|
f"backend; remove the 'runtime' field from the bottle "
|
||||||
|
f"definition."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ssh" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has an 'ssh' field, which has been removed "
|
||||||
|
f"(PRD 0009). Declare upstreams under 'git-gate.repos' with "
|
||||||
|
f"url + identity + host_key; the git-gate sidecar (PRD 0008) "
|
||||||
|
f"holds the credential and gitleaks-scans pushes."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "git" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' uses 'git' which has been replaced by "
|
||||||
|
f"'git-gate' (PRD 0047). Move git.user → git-gate.user "
|
||||||
|
f"and git.remotes → git-gate.repos (fields: url, identity, host_key)."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "git_user" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has a 'git_user' field, which has been "
|
||||||
|
f"removed. Move it under 'git-gate.user'."
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(d.keys()) - BOTTLE_KEYS
|
||||||
|
if unknown:
|
||||||
|
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has unknown key(s) {sorted(unknown)}; "
|
||||||
|
f"allowed keys are {allowed}."
|
||||||
|
)
|
||||||
|
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
env_raw = d.get("env")
|
||||||
|
if env_raw is not None:
|
||||||
|
env_dict = as_json_object(env_raw, f"bottle '{name}' env")
|
||||||
|
for var, value in env_dict.items():
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ManifestError(
|
||||||
|
f"env entry {var} in bottle '{name}' must be a JSON string "
|
||||||
|
f"(was {type(value).__name__}). Use \"?<message>\" for prompt-at-runtime."
|
||||||
|
)
|
||||||
|
env[var] = value
|
||||||
|
|
||||||
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
|
git_user = ManifestGitUser()
|
||||||
|
git_raw = d.get("git-gate")
|
||||||
|
if git_raw is not None:
|
||||||
|
git, git_user = parse_git_gate_config(name, git_raw)
|
||||||
|
|
||||||
|
agent_provider = (
|
||||||
|
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||||
|
if "agent_provider" in d
|
||||||
|
else ManifestAgentProvider()
|
||||||
|
)
|
||||||
|
|
||||||
|
egress = (
|
||||||
|
ManifestEgressConfig.from_dict(name, d["egress"])
|
||||||
|
if "egress" in d
|
||||||
|
else ManifestEgressConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
supervise_raw = d.get("supervise", True)
|
||||||
|
if not isinstance(supervise_raw, bool):
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' supervise must be a boolean "
|
||||||
|
f"(was {type(supervise_raw).__name__})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
env=env, agent_provider=agent_provider, git=git,
|
||||||
|
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||||
|
)
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from .manifest_bottle import ManifestBottle
|
||||||
|
from .manifest_egress import ManifestEgressConfig, validate_egress_routes
|
||||||
if TYPE_CHECKING:
|
from .manifest_git import ManifestGitUser, parse_git_gate_config
|
||||||
from .manifest import ManifestBottle
|
from .manifest_util import ManifestError, as_json_object
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
|
|
||||||
|
|
||||||
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||||
@@ -27,9 +26,6 @@ def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
|||||||
|
|
||||||
|
|
||||||
def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle":
|
def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle":
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
|
|
||||||
merged_env = {**base.env, **override.env}
|
merged_env = {**base.env, **override.env}
|
||||||
|
|
||||||
merged_git_user = ManifestGitUser(
|
merged_git_user = ManifestGitUser(
|
||||||
@@ -81,8 +77,6 @@ def _resolve_one_bottle(
|
|||||||
repos_cache: dict[str, dict[str, object]],
|
repos_cache: dict[str, dict[str, object]],
|
||||||
seen: tuple[str, ...],
|
seen: tuple[str, ...],
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
from .manifest import ManifestBottle, ManifestError
|
|
||||||
|
|
||||||
if name in cache:
|
if name in cache:
|
||||||
return cache[name]
|
return cache[name]
|
||||||
if name in seen:
|
if name in seen:
|
||||||
@@ -174,11 +168,6 @@ def _fold_two_bottles(
|
|||||||
later_repos_raw: dict[str, object],
|
later_repos_raw: dict[str, object],
|
||||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||||
"""Combine two resolved parent bottles; later wins over earlier."""
|
"""Combine two resolved parent bottles; later wins over earlier."""
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
from .manifest_git import parse_git_gate_config
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
merged_env = {**earlier.env, **later.env}
|
merged_env = {**earlier.env, **later.env}
|
||||||
|
|
||||||
merged_git_user = ManifestGitUser(
|
merged_git_user = ManifestGitUser(
|
||||||
@@ -227,10 +216,6 @@ def _merge_bottles(
|
|||||||
name: str,
|
name: str,
|
||||||
) -> ManifestBottle:
|
) -> ManifestBottle:
|
||||||
"""Apply PRD 0025 merge rules."""
|
"""Apply PRD 0025 merge rules."""
|
||||||
from .manifest import ManifestBottle, ManifestGitUser
|
|
||||||
from .manifest_egress import validate_egress_routes
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
# git-gate.repos: when the child declares repos, inject the already
|
# git-gate.repos: when the child declares repos, inject the already
|
||||||
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
||||||
# parses with the full inherited+overridden list (issue #237).
|
# parses with the full inherited+overridden list (issue #237).
|
||||||
@@ -303,8 +288,6 @@ def _resolve_repos_raw(
|
|||||||
inherits the parent's set verbatim; an explicit empty dict clears it.
|
inherits the parent's set verbatim; an explicit empty dict clears it.
|
||||||
Otherwise parent and child unite by name, with same-name entries
|
Otherwise parent and child unite by name, with same-name entries
|
||||||
field-merged (parent fields are defaults, child fields win)."""
|
field-merged (parent fields are defaults, child fields win)."""
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
if not _child_declares_git_gate_repos(child_raw):
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
return parent_repos
|
return parent_repos
|
||||||
child_repos = _declared_repos_raw(child_raw)
|
child_repos = _declared_repos_raw(child_raw)
|
||||||
@@ -324,8 +307,6 @@ def _resolve_repos_raw(
|
|||||||
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
||||||
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
||||||
or an empty dict when none are declared."""
|
or an empty dict when none are declared."""
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
if not _child_declares_git_gate_repos(child_raw):
|
if not _child_declares_git_gate_repos(child_raw):
|
||||||
return {}
|
return {}
|
||||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||||
@@ -333,8 +314,6 @@ def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
git_raw = child_raw.get("git-gate")
|
git_raw = child_raw.get("git-gate")
|
||||||
if git_raw is None:
|
if git_raw is None:
|
||||||
return False
|
return False
|
||||||
@@ -347,9 +326,6 @@ def _merge_egress(
|
|||||||
child: ManifestEgressConfig,
|
child: ManifestEgressConfig,
|
||||||
child_raw: dict[str, object],
|
child_raw: dict[str, object],
|
||||||
) -> ManifestEgressConfig:
|
) -> ManifestEgressConfig:
|
||||||
from .manifest_egress import ManifestEgressConfig
|
|
||||||
from .manifest_util import as_json_object
|
|
||||||
|
|
||||||
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
|
||||||
routes = parent.routes + child.routes
|
routes = parent.routes + child.routes
|
||||||
log = child.Log if "log" in child_egress_raw else parent.Log
|
log = child.Log if "log" in child_egress_raw else parent.Log
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .log import warn
|
from .log import warn
|
||||||
|
from .manifest_bottle import ManifestBottle
|
||||||
|
from .manifest_extends import resolve_bottles
|
||||||
from .manifest_schema import (
|
from .manifest_schema import (
|
||||||
entity_name_from_path,
|
entity_name_from_path,
|
||||||
validate_bottle_frontmatter_keys,
|
validate_bottle_frontmatter_keys,
|
||||||
@@ -13,9 +14,6 @@ from .manifest_schema import (
|
|||||||
from .manifest_util import ManifestError
|
from .manifest_util import ManifestError
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .manifest import ManifestBottle
|
|
||||||
|
|
||||||
|
|
||||||
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||||
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||||
@@ -78,8 +76,6 @@ def load_bottle_chain_from_dir(
|
|||||||
|
|
||||||
Only the files in the extends chain are read — unrelated bottle files
|
Only the files in the extends chain are read — unrelated bottle files
|
||||||
are never touched. Raises ManifestError on parse or validation failure."""
|
are never touched. Raises ManifestError on parse or validation failure."""
|
||||||
from .manifest_extends import resolve_bottles
|
|
||||||
|
|
||||||
raws: dict[str, dict[str, object]] = {}
|
raws: dict[str, dict[str, object]] = {}
|
||||||
to_load = [bottle_name]
|
to_load = [bottle_name]
|
||||||
while to_load:
|
while to_load:
|
||||||
|
|||||||
@@ -33,13 +33,20 @@ AGENT_KEYS = (
|
|||||||
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
|
AGENT_MODEL_KEYS = AGENT_KEYS | frozenset({"prompt"})
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_entity_name(name: str) -> bool:
|
||||||
|
"""True if `name` fits the kebab-case `[a-z][a-z0-9-]*` convention
|
||||||
|
shared by bottle/agent filenames and skill names. Names that satisfy
|
||||||
|
this are also safe to interpolate into a host/guest path segment."""
|
||||||
|
return bool(_FILENAME_RX.match(name))
|
||||||
|
|
||||||
|
|
||||||
def entity_name_from_path(path: Path) -> str | None:
|
def entity_name_from_path(path: Path) -> str | None:
|
||||||
"""Return the entity name implied by the filename, or None if the
|
"""Return the entity name implied by the filename, or None if the
|
||||||
filename does not fit the [a-z][a-z0-9-]* convention."""
|
filename does not fit the [a-z][a-z0-9-]* convention."""
|
||||||
if path.suffix != ".md":
|
if path.suffix != ".md":
|
||||||
return None
|
return None
|
||||||
stem = path.stem
|
stem = path.stem
|
||||||
if not _FILENAME_RX.match(stem):
|
if not is_valid_entity_name(stem):
|
||||||
return None
|
return None
|
||||||
return stem
|
return stem
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Monetization & competitive positioning
|
||||||
|
|
||||||
|
Where, if anywhere, bot-bottle has a paid wedge — given a 2026
|
||||||
|
competitive field that has largely commoditized "sandbox a coding
|
||||||
|
agent." Folds together the agent-provider-agnostic framing, the Fly
|
||||||
|
remote-backend idea, the supervisor/egress-audit play, and the
|
||||||
|
solo-dev/Linux brand instinct, then asks the only question that
|
||||||
|
matters: is there a viable path to revenue that the competition does
|
||||||
|
not already foreclose?
|
||||||
|
|
||||||
|
Companion to
|
||||||
|
[`agent-sandbox-landscape.md`](agent-sandbox-landscape.md) (the
|
||||||
|
isolation-tech survey),
|
||||||
|
[`built-in-supervisor-design.md`](built-in-supervisor-design.md) (the
|
||||||
|
supervise surface this would extend), and
|
||||||
|
[`secret-minimization-over-dlp.md`](secret-minimization-over-dlp.md)
|
||||||
|
(why custody, not detection, is the real moat).
|
||||||
|
|
||||||
|
Market data current as of June 2026.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Verdict: a path exists, but it is narrow, and it is not the path the
|
||||||
|
project is currently shaped for.** Every individual property bot-bottle
|
||||||
|
leans on — isolation, BYO-image, egress filtering, OSS, self-hosting —
|
||||||
|
is matched by some competitor, and several are now *free* from the agent
|
||||||
|
vendors themselves. There is exactly one defensible position left: the
|
||||||
|
**bundle** that no single competitor occupies —
|
||||||
|
|
||||||
|
> uniform egress audit + secret custody + policy, across *heterogeneous
|
||||||
|
> coding agents you don't trust*, on your infra or a managed pool.
|
||||||
|
|
||||||
|
Monetization is viable **only** if the product is sold as cross-vendor
|
||||||
|
**fleet governance + egress audit for teams**, not as solo-dev agent
|
||||||
|
safety (which the labs give away free). The solo-dev/Linux/anti-corporate
|
||||||
|
energy is real and worth using — but as a *distribution and trust*
|
||||||
|
engine that drives bottom-up adoption into teams, never as the revenue
|
||||||
|
positioning itself. Get those two wires crossed and the business dies:
|
||||||
|
you'd be courting the lowest-willingness-to-pay audience on earth while
|
||||||
|
repelling the only buyer who pays.
|
||||||
|
|
||||||
|
Net: **viable, conditional, and unforgiving of positioning error.** Do
|
||||||
|
Phase 1 (self-hostable egress-audit dashboard) regardless — it's
|
||||||
|
low-risk and it's the demo that makes everything else legible. Gate the
|
||||||
|
go/no-go on whether 5–10 teams confirm they'd pay for cross-vendor
|
||||||
|
egress audit *before* building the hosted tier.
|
||||||
|
|
||||||
|
## The two axes of "agnostic"
|
||||||
|
|
||||||
|
bot-bottle differentiates on two orthogonal axes, and conflating them
|
||||||
|
muddies the pitch:
|
||||||
|
|
||||||
|
1. **Agent-provider agnostic** — run Claude Code, Codex, Aider, a local
|
||||||
|
model, behind one control layer. Already real in the code
|
||||||
|
(`agent_provider.py`, Claude/Codex templates, BYO Dockerfile). This
|
||||||
|
is the axis the labs *structurally cannot* match — Anthropic only
|
||||||
|
runs Claude, OpenAI only their models. Durable.
|
||||||
|
2. **Compute backend** — local (docker / Apple Container / smolmachines)
|
||||||
|
today; a remote **Fly** backend would add a managed pool. This is the
|
||||||
|
axis that makes "fleet" literal for orgs and opens metered billing.
|
||||||
|
Fly is a strong first remote backend because it also subsumes remote
|
||||||
|
spin-up (Machines API) and the tunnel problem (6PN/WireGuard) — but
|
||||||
|
"provider-agnostic compute" should be *earned* after backend #2, not
|
||||||
|
designed up front (premature generalization trap).
|
||||||
|
|
||||||
|
## Competitive field, by capability
|
||||||
|
|
||||||
|
The field doesn't have one competitor; it has a different set on each
|
||||||
|
capability bot-bottle touches. Five dimensions:
|
||||||
|
|
||||||
|
| Capability | Who has it | bot-bottle's standing |
|
||||||
|
| :-- | :-- | :-- |
|
||||||
|
| **Isolation / sandbox** | Anthropic & OpenAI **native, free**; OSS devcontainer wrappers; E2B/Modal/Daytona/Northflank | Commoditized. Not a wedge. |
|
||||||
|
| **Arbitrary BYO Docker image** | Sandbox PaaS (E2B/Modal/Daytona/Northflank) yes; **managed agents: ~none** (Codex = fixed `codex-universal` + setup scripts; Copilot "not supported"; Devin/Jules constrained) | Wedge **vs. managed agents** (structural: it's their infra). Table stakes vs. PaaS. |
|
||||||
|
| **Egress audit + alerts** | LLM-observability tools (Braintrust/Langfuse/Phoenix/Helicone/Datadog) — but on *model calls*, wrong layer. Network-egress security (DeepInspect, AI gateways) — right layer, but decoupled from the agent, not cross-vendor. Sandbox PaaS = gateway/filter, not an audit surface. | **~Nobody in bot-bottle's exact shape** (per-agent egress, tied to the sandbox, with DLP context, cross-vendor). This is the wedge. |
|
||||||
|
| **OSS / self-hosting** | Managed agents: ~none. Sandbox PaaS: ~half (E2B OSS+self-host; Northflank BYOC; Modal closed; **Daytona leaving OSS**). Devcontainer wrappers: ~all. Observability: several. | Real wedge **vs. managed agents only**. Table stakes vs. PaaS, zero differentiation vs. wrappers. |
|
||||||
|
| **Cross-vendor uniformity** | Nobody — the labs won't, PaaS is agent-neutral infra not agent-aware control, wrappers are single-tool | Wedge. The connective tissue of the whole position. |
|
||||||
|
|
||||||
|
The pattern: **isolation and OSS/self-host are commodity; BYO-image and
|
||||||
|
cross-vendor are wedges only against the managed agents; egress-audit in
|
||||||
|
the integrated form is the one thing genuinely unoccupied.**
|
||||||
|
|
||||||
|
## Where bot-bottle is alone vs. where it's table stakes
|
||||||
|
|
||||||
|
- **Alone (the moat):** egress audit + secret custody + policy, *tied to
|
||||||
|
the agent sandbox*, *with DLP context* (which secret, which host,
|
||||||
|
which agent/task), *uniform across vendors*. No competitor bundles
|
||||||
|
these. An enterprise *could* bolt DeepInspect-style egress monitoring
|
||||||
|
onto a sandbox, so the defensibility is the **integration and
|
||||||
|
per-agent context**, not "we can see egress."
|
||||||
|
- **Table stakes (do not lead with these):** "we sandbox agents" (free
|
||||||
|
from the labs), "we're open source" (E2B is; the wrapper crowd all
|
||||||
|
is), "we self-host" (Northflank BYOC, E2B, every wrapper).
|
||||||
|
|
||||||
|
## The two existential competitive facts
|
||||||
|
|
||||||
|
1. **The agent vendors ship good-enough sandboxing for free.** Claude
|
||||||
|
Code now has Seatbelt/bubblewrap + a network proxy natively; Codex
|
||||||
|
has its own sandbox + approvals. This compresses the *single-vendor,
|
||||||
|
single-dev* market to ~zero willingness-to-pay. It is *why* the
|
||||||
|
product must be cross-vendor fleet governance, not local agent
|
||||||
|
safety.
|
||||||
|
2. **Northflank is converging from the infra side.** It already ships
|
||||||
|
dedicated egress gateways + proxy-based secret injection + BYOC.
|
||||||
|
It is the nearest thing to bot-bottle's differentiator as a managed
|
||||||
|
platform — but infra-first and agent-neutral, not agent-aware,
|
||||||
|
cross-vendor, or audit-first. Watch it.
|
||||||
|
|
||||||
|
## Monetization path (sequenced)
|
||||||
|
|
||||||
|
Open-core: **give away the sandbox, charge for the control plane.**
|
||||||
|
|
||||||
|
- **Phase 0 — validate (1–2 wks, parallel).** Ask 5–10 teams running 2+
|
||||||
|
agents: would you pay for one egress-audit + policy plane across
|
||||||
|
Claude *and* Codex? Gate the rest on a yes.
|
||||||
|
- **Phase 1 — the wedge (self-hostable, OSS).** Multi-bottle egress
|
||||||
|
dashboard + web approval queue + exportable audit log, built over the
|
||||||
|
existing `supervise_server.py` JSON-RPC and the egress event levels
|
||||||
|
(`LOG_BLOCKS` / `LOG_FULL`). Low risk, half-built, and the 30-second
|
||||||
|
demo that sells everything. The compliance hook (75% of enterprises
|
||||||
|
rank auditability #1) lives here.
|
||||||
|
- **Phase 2 — the paywall (hosted team tier).** Multi-tenant supervisor:
|
||||||
|
SSO/RBAC, audit retention, alerting, **centralized policy push**
|
||||||
|
(define egress allowlist + DLP once, enforce across all agents —
|
||||||
|
the moat made concrete). Gate on team/compliance features, *never* on
|
||||||
|
the core security.
|
||||||
|
- **Phase 3 — Fly remote backend.** Managed agent pool → "fleet" becomes
|
||||||
|
literal; metered (agent-hours) billing; subsumes remote spin-up +
|
||||||
|
tunnel.
|
||||||
|
- **Phase 4 — deepen.** Second agent provider done deeply (lean
|
||||||
|
open-source/open-weight for rug-pull resistance); egress anomaly
|
||||||
|
detection (the DLP stream becomes a product); SOC2/audit-export for
|
||||||
|
larger buyers.
|
||||||
|
|
||||||
|
**Do not build first:** the p2p mobile app (least monetizable, 6PN
|
||||||
|
gives the tunnel free), a generic multi-cloud abstraction (premature),
|
||||||
|
or the hosted SaaS before Phase 0.
|
||||||
|
|
||||||
|
## Brand vs. revenue: the solo-dev / Linux instinct
|
||||||
|
|
||||||
|
The instinct to court Linux/hacker/solo-dev users and stay "not too
|
||||||
|
corporate" is **right for distribution, dangerous as strategy.**
|
||||||
|
|
||||||
|
- **Right:** it's how OSS infra gets discovered and trusted (HN, stars,
|
||||||
|
word-of-mouth, security-circle vouching); authenticity is a real moat
|
||||||
|
vs. the corporate players *because the architecture sincerely embodies
|
||||||
|
it* (local-first, `$HOME` trust boundary, no phone-home); and it fits
|
||||||
|
the founder.
|
||||||
|
- **Dangerous:** that audience is the lowest-WTP cohort that exists
|
||||||
|
(self-hosts the free thing, forks rather than pays), and "not too
|
||||||
|
corporate" reads to a VP of Eng as "not enterprise-ready." Building an
|
||||||
|
anti-SaaS brand and then shipping a paid tier invites the sell-out /
|
||||||
|
rug-pull backlash — which **Daytona just triggered** going closed.
|
||||||
|
|
||||||
|
**Resolution — be Tailscale, not a manifesto.** Use the developer-first,
|
||||||
|
respects-you energy as the *funnel*; sell *through* the solo advocate,
|
||||||
|
bottom-up, into the team that pays. Two guardrails:
|
||||||
|
|
||||||
|
1. "Anti-corporate" must not mean "anti-team-features." SSO/RBAC/audit
|
||||||
|
retention *are* the monetization; build them in a developer-respecting
|
||||||
|
way (Tailscale has SSO and is still beloved). Tone is the brand; team
|
||||||
|
features are the product.
|
||||||
|
2. Set the open-core social contract publicly **on day one** — core
|
||||||
|
sandbox open and self-hostable forever; hosted control plane is how
|
||||||
|
the lights stay on. The communities that don't revolt are the ones
|
||||||
|
told the deal upfront.
|
||||||
|
|
||||||
|
Concrete: the README frames the Docker/**Linux** backend as "legacy."
|
||||||
|
If courting the Linux crowd, make the Linux path (Docker+gVisor,
|
||||||
|
libkrun/smolmachines) first-class in the docs, not the fallback.
|
||||||
|
|
||||||
|
## Individuals, mobile, and the Pi-ecosystem reality check
|
||||||
|
|
||||||
|
"Individual devs won't pay" (above) is too blunt and needs refining.
|
||||||
|
The accurate claim: individuals won't pay for **safety-as-insurance**
|
||||||
|
(abstract risk reduction the labs give away free), but they *do* pay for
|
||||||
|
**capability/convenience felt daily** — Claude Pro, Cursor, Tailscale
|
||||||
|
Personal. "Drive my self-hosted agent from my phone" is capability, not
|
||||||
|
insurance, so it has a real (low-priced, high-churn) WTP profile. The
|
||||||
|
self-hoster/Linux crowd specifically pays for **sovereignty/control**,
|
||||||
|
just not for enterprise insurance. So an individual "sovereign remote
|
||||||
|
agent access" tier is *not* unreasonable in principle.
|
||||||
|
|
||||||
|
**But the market has already run that experiment, in public, for free.**
|
||||||
|
The Pi ecosystem (pi.dev) has commoditized every convenience layer an
|
||||||
|
individual product would charge for:
|
||||||
|
|
||||||
|
| Capability | Already free/OSS | bot-bottle differentiates? |
|
||||||
|
| :-- | :-- | :-- |
|
||||||
|
| Remote control from mobile | remote-pi, Paseo, TelePi | ❌ commoditized |
|
||||||
|
| Multi-agent orchestration from mobile | Paseo, pi-agent-dashboard | ❌ commoditized |
|
||||||
|
| **Launch** new agents from mobile | Paseo (`paseo run`) | ❌ commoditized |
|
||||||
|
| Launch into a **sandboxed, egress-audited** env | nobody | ✅ the moat |
|
||||||
|
|
||||||
|
Paseo (`getpaseo/paseo`, on the App Store) does the full thing an
|
||||||
|
individual remote-control tier would charge for — launch *and* attach
|
||||||
|
agents on a laptop/VM/dev-server, driven from mobile over an E2E relay —
|
||||||
|
free and open source. It *orchestrates* agents; it does **not** sandbox them, run
|
||||||
|
an egress chokepoint, DLP-scan, or audit. None of the Pi-ecosystem tools
|
||||||
|
do. So the residue, yet again, is **isolation + governance**, not
|
||||||
|
remote/launch convenience.
|
||||||
|
|
||||||
|
Two takeaways:
|
||||||
|
1. **Don't compete on orchestration/launch/remote UX** — it's a solved,
|
||||||
|
free, fast-moving, App-Store-shipping space around Pi. You won't win
|
||||||
|
it and it isn't the moat.
|
||||||
|
2. **Be the safe runtime orchestrators launch *into*.** Launch-from-mobile
|
||||||
|
is table stakes; *launch-into-a-sealed-egress-audited-bottle* is the
|
||||||
|
differentiator. bot-bottle is the sandbox an orchestrator like Paseo
|
||||||
|
would target, or that you wrap thin orchestration around — never the
|
||||||
|
orchestrator itself.
|
||||||
|
|
||||||
|
Capability layers commoditize fast: every individual/mobile angle
|
||||||
|
probed in this analysis collapsed back to the same cross-vendor +
|
||||||
|
sandbox + egress-audit + custody bundle. Mobile remote belongs as a
|
||||||
|
*funnel delighter* on top of the team product, not a standalone paid
|
||||||
|
line.
|
||||||
|
|
||||||
|
## Forge-native orchestration as the delivery vehicle
|
||||||
|
|
||||||
|
The strongest concrete *product shape* for the moat is not a bespoke
|
||||||
|
dashboard and not a Paseo competitor — it is **the git forge as the
|
||||||
|
orchestrator, with bot-bottle as the safe runtime it launches into.**
|
||||||
|
The forge already provides, for free, everything an orchestrator would
|
||||||
|
otherwise have to build: identity (agent/bot users, signed commits),
|
||||||
|
state (issues, labels, PRs/MRs, comments), triggers (webhooks, CI,
|
||||||
|
comment commands), review (diffs, approvals, status checks), audit
|
||||||
|
(commits/comments/reviews), and permissions (repo access, protected
|
||||||
|
branches, token scopes). bot-bottle supplies the one thing the forge
|
||||||
|
doesn't: **least-privilege, secret-isolated, audited execution of
|
||||||
|
untrusted agents.** Same moat (custody + audit + policy), better
|
||||||
|
vehicle — and it lands the product where teams already live, so it
|
||||||
|
avoids building an agent dashboard before one is needed.
|
||||||
|
|
||||||
|
The flow is essentially free to assemble:
|
||||||
|
|
||||||
|
```
|
||||||
|
issue/PR/MR event → webhook → policy/router → assign agent user +
|
||||||
|
branch/worktree → run agent in an isolated bottle (no ambient secrets)
|
||||||
|
→ commit as agent identity → open PR/MR → CI + human review + merge
|
||||||
|
```
|
||||||
|
|
||||||
|
**Crowding (why this is less saturated than it looks):**
|
||||||
|
|
||||||
|
| Layer | How crowded |
|
||||||
|
| :-- | :-- |
|
||||||
|
| Generic multi-agent orchestrators (worktree/TUI/dashboard) | very — 50–100+ |
|
||||||
|
| Forge-native issue/PR/MR orchestration | moderate — ~10–30 serious |
|
||||||
|
| Self-hostable, least-privilege, audited, forge-portable | **single digits** |
|
||||||
|
|
||||||
|
The deeper you go toward *untrusted-agent safety + auditability +
|
||||||
|
self-hostable + forge-portable*, the emptier it gets.
|
||||||
|
|
||||||
|
**The GitHub/GitLab first-party trap → lead Gitea + sovereignty.**
|
||||||
|
GitHub (Agentic Workflows, Copilot coding agent) and GitLab (Duo Agent
|
||||||
|
Platform) are the forge *vendors* building native issue-to-PR agent
|
||||||
|
orchestration with native identity/permissions/audit. On their turf you
|
||||||
|
lose the integration-depth battle the same way single-vendor agent
|
||||||
|
safety loses to Anthropic/OpenAI — the same "incumbent ships it free,
|
||||||
|
deeper" dynamic, one layer up. So the durable opening is **Gitea +
|
||||||
|
self-hosted** (no first-party agent platform exists — the open Gitea
|
||||||
|
feature request for an AI code agent confirms the vacuum) plus
|
||||||
|
**cross-forge *untrusted-agent* safety**, which no forge vendor will
|
||||||
|
build because they want you running *their* agent, not arbitrary ones
|
||||||
|
under uniform least-privilege across competitors' forges. Cross-vendor
|
||||||
|
neutrality, applied to forges.
|
||||||
|
|
||||||
|
**Buyer reconciliation.** The least-crowded opening (self-hosted Gitea)
|
||||||
|
overlaps the lowest-WTP crowd (indie self-hosters), while the paying
|
||||||
|
teams sit on GitHub/GitLab where first-party competition is fiercest.
|
||||||
|
The intersection that resolves it: **orgs running self-hosted forges for
|
||||||
|
sovereignty/compliance reasons** (regulated, air-gapped, security-
|
||||||
|
conscious, on-prem). They have budget, they run self-hosted GitLab/Gitea,
|
||||||
|
*and* shipping code to a cloud agent vendor is a non-starter — so "run
|
||||||
|
untrusted agents sandboxed, least-privilege, fully audited, inside our
|
||||||
|
forge, on our infra" is a procurement checkbox, not a nicety. That is
|
||||||
|
where "least-crowded" finally meets "has money."
|
||||||
|
|
||||||
|
**Separate moat-hard-parts from cost-hard-parts.** The orchestration
|
||||||
|
"hard parts" are two different things, and conflating them oversells the
|
||||||
|
fit:
|
||||||
|
|
||||||
|
| Moat (your differentiated strength) | Undifferentiated cost (everyone faces) |
|
||||||
|
| :-- | :-- |
|
||||||
|
| permission isolation | idempotency / dedupe / run ledger |
|
||||||
|
| secret handling under malicious prompts | concurrency, locks, cancellation |
|
||||||
|
| run provenance | queueing / scheduling / cleanup |
|
||||||
|
| policy language | merge-conflict handling (~27% agent-PR conflict rate) |
|
||||||
|
|
||||||
|
The right column is generic distributed-systems plumbing that wins you
|
||||||
|
nothing and that merge-conflict resolution especially is a *different
|
||||||
|
competency* from sandbox/custody. Keep it thin in the MVP; do not build a
|
||||||
|
policy DSL + durable ledger + conflict resolver before one org pays.
|
||||||
|
|
||||||
|
**The killer feature: run provenance on every agent PR.** A check/comment
|
||||||
|
answering — which agent, which model, which prompt, which base commit,
|
||||||
|
which policy, which tools, which network egress, which test results —
|
||||||
|
attached at the moment a human reviews. It renders the (invisible)
|
||||||
|
custody + egress-audit work as a PR artifact the buyer sees at the exact
|
||||||
|
trust-decision point. No forge vendor's first-party agent will show you
|
||||||
|
"here is everything the untrusted agent could reach." Build this first.
|
||||||
|
|
||||||
|
**MVP** (`@bot-bottle fix this`): create an isolated worktree/bottle →
|
||||||
|
check out the issue branch → run the selected harness as a named agent
|
||||||
|
user → deny ambient secrets by default → record prompt/model/tools/policy
|
||||||
|
→ commit with bot identity → open PR/MR → attach the run-provenance
|
||||||
|
footer (log + tests + permission/egress summary) → require human merge.
|
||||||
|
The security model *is* the product. This rides the headless launch
|
||||||
|
primitive directly: webhook → `start --headless` into an isolated bottle
|
||||||
|
→ commit as agent identity → PR with provenance.
|
||||||
|
|
||||||
|
Open-core line is unchanged: the webhook/comment trigger stays free
|
||||||
|
(adoption); the sandboxed-execution + provenance + policy layer is the
|
||||||
|
paid governance.
|
||||||
|
|
||||||
|
## Risks to the thesis
|
||||||
|
|
||||||
|
- **Lab encroachment.** If Anthropic/OpenAI add cross-agent governance
|
||||||
|
or open their managed egress logs, the wedge narrows. Mitigate by
|
||||||
|
going deep on cross-vendor + custody + audit *now*, while they're
|
||||||
|
single-vendor.
|
||||||
|
- **Rug-pull dependency.** You run the labs' agents; they can restrict
|
||||||
|
their agent to their own sandbox via ToS/tech. Hedge toward
|
||||||
|
open-source/open-weight agents for durability.
|
||||||
|
- **Northflank (or E2B) ships agent-aware audit.** Plausible from the
|
||||||
|
infra side. Your defense is agent-awareness + the supervise approval
|
||||||
|
loop + cross-vendor, not raw egress visibility.
|
||||||
|
- **WTP may simply not be there.** The honest failure mode: teams like
|
||||||
|
the audit but won't pay because "we already sandbox in CI." Phase 0
|
||||||
|
exists to find this out cheaply before building Phase 2/3.
|
||||||
|
- **Forge-vendor encroachment (forge-native path).** GitHub Agentic
|
||||||
|
Workflows / Copilot and GitLab Duo are first-party and deepening.
|
||||||
|
Defense: aim at self-hosted Gitea + sovereignty buyers where no
|
||||||
|
first-party agent platform exists, and at cross-forge untrusted-agent
|
||||||
|
neutrality the vendors won't build. Don't fight them GitHub-native.
|
||||||
|
- **Orchestration-reliability scope creep.** The forge-native build
|
||||||
|
drags in idempotency, queueing, concurrency, and merge-conflict
|
||||||
|
handling — undifferentiated plumbing that isn't the moat. Keep it thin
|
||||||
|
until a paying org forces it.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Build Phase 1 now — it's low-risk, half-built, and the proof artifact.
|
||||||
|
Run Phase 0 in parallel. Treat a clear yes from 5–10 teams as the
|
||||||
|
green light for the hosted tier; treat a soft maybe as a signal to stay
|
||||||
|
an excellent OSS tool with a tip-jar/support model rather than a
|
||||||
|
venture-shaped SaaS. The technology is not the risk — the codebase is
|
||||||
|
exemplary and the architecture already supports the pivot. The risk is
|
||||||
|
**positioning discipline**: sell cross-vendor fleet governance to teams,
|
||||||
|
use the indie brand as the funnel, and never let the anti-corporate
|
||||||
|
aesthetic veto the features that pay.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Anthropic — Claude Code sandboxing:
|
||||||
|
https://www.anthropic.com/engineering/claude-code-sandboxing
|
||||||
|
- OpenAI Codex — cloud environments:
|
||||||
|
https://developers.openai.com/codex/cloud/environments ;
|
||||||
|
custom-image feature request:
|
||||||
|
https://community.openai.com/t/feature-request-custom-docker-images/1265333
|
||||||
|
- GitHub Copilot — custom container image (not supported), discussion
|
||||||
|
#194105: https://github.com/orgs/community/discussions/194105
|
||||||
|
- DeepInspect — AI egress monitoring:
|
||||||
|
https://www.deepinspect.ai/blog/ai-egress-monitoring
|
||||||
|
- Braintrust — AI agent observability/alerting:
|
||||||
|
https://www.braintrust.dev/articles/best-ai-agent-observability-tools-2026
|
||||||
|
- E2B (OSS, Apache-2.0): https://github.com/e2b-dev/e2b ;
|
||||||
|
infra/self-host: https://github.com/e2b-dev/infra
|
||||||
|
- Daytona going closed source:
|
||||||
|
https://www.daytona.io/dotfiles/updates/daytona-is-going-closed-source
|
||||||
|
- Northflank — BYOC / egress gateways:
|
||||||
|
https://northflank.com/blog/what-is-byoc-in-cloud-computing ;
|
||||||
|
https://northflank.com/blog/self-hostable-alternatives-to-e2b-for-ai-agents
|
||||||
|
- Modal Sandboxes: https://modal.com/products/sandboxes
|
||||||
|
- AI agent orchestration / enterprise governance (75% cite
|
||||||
|
auditability):
|
||||||
|
https://viston.tech/ai-agent-orchestration-in-2026-moving-from-pilots-to-enterprise-wide-execution/
|
||||||
|
- Pi harness (provider-agnostic CLI): https://pi.dev/packages/remote-pi ;
|
||||||
|
https://github.com/earendil-works/pi
|
||||||
|
- Paseo (launch + attach agents from desktop/mobile, OSS):
|
||||||
|
https://github.com/getpaseo/paseo ;
|
||||||
|
https://apps.apple.com/us/app/paseo-remote-coding-agents/id6758887924
|
||||||
|
- pi-agent-dashboard (mobile-first remote control via mDNS/zrok):
|
||||||
|
https://github.com/BlackBeltTechnology/pi-agent-dashboard
|
||||||
|
- TelePi (Telegram remote control for Pi):
|
||||||
|
https://futurelab.studio/blog/telepi-telegram-remote-control-for-pi/
|
||||||
|
- Forge-native landscape (provided via conversation, not independently
|
||||||
|
re-verified):
|
||||||
|
- awesome-agent-orchestrators (50+ generic orchestrators):
|
||||||
|
https://github.com/andyrewlee/awesome-agent-orchestrators
|
||||||
|
- GitHub Agentic Workflows (first-party repo automation):
|
||||||
|
https://github.blog/ai-and-ml/automate-repository-tasks-with-github-agentic-workflows/
|
||||||
|
- GitLab Duo Agent Platform GA:
|
||||||
|
https://ir.gitlab.com/news/news-details/2026/GitLab-Announces-the-General-Availability-of-GitLab-Duo-Agent-Platform/default.aspx
|
||||||
|
- ai-review (cross-forge review incl. Gitea):
|
||||||
|
https://github.com/Nikita-Filonov/ai-review
|
||||||
|
- Gitea feature request — AI code agent (the vacuum):
|
||||||
|
https://github.com/go-gitea/gitea/issues/34527
|
||||||
|
- Phoenix — safe GitHub issue resolution (label-based webhook state
|
||||||
|
machine): https://arxiv.org/abs/2606.20243
|
||||||
|
- AgenticFlict — ~27% merge-conflict rate in agent PRs:
|
||||||
|
https://arxiv.org/abs/2604.03551
|
||||||
@@ -165,6 +165,22 @@ class TestAgentValidation(unittest.TestCase):
|
|||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"skills": [5]}, set())
|
ManifestAgent.from_dict("a", {"skills": [5]}, set())
|
||||||
|
|
||||||
|
def test_skill_name_rejects_shell_metacharacters(self) -> None:
|
||||||
|
# Skill names become host/guest path segments interpolated into
|
||||||
|
# provisioning shell commands; anything outside kebab-case is
|
||||||
|
# rejected at load so it can never reach a `bottle.exec` string.
|
||||||
|
for bad in ("foo; rm -rf /", "../escape", "foo bar", "Foo", "-leading"):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
ManifestAgent.from_dict("a", {"skills": [bad]}, set())
|
||||||
|
|
||||||
|
def test_skill_name_accepts_kebab_case(self) -> None:
|
||||||
|
agent = ManifestAgent.from_dict(
|
||||||
|
"a", {"skills": ["init-entry", "quality-eval", "skill0"]}, set()
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
agent.skills, ("init-entry", "quality-eval", "skill0")
|
||||||
|
)
|
||||||
|
|
||||||
def test_prompt_not_string(self) -> None:
|
def test_prompt_not_string(self) -> None:
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
||||||
|
|||||||
Reference in New Issue
Block a user