Skip to content

Commit 8482bf4

Browse files
committed
Configure persistent agent clients in Debian guest seeds
1 parent d378cb9 commit 8482bf4

3 files changed

Lines changed: 275 additions & 4 deletions

File tree

‎sandbox/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ installs and starts its SSH and broker relays. It does not prepare missing
107107
packages or fetch dependencies. No host services are installed or activated by
108108
generating seeds, profiles, unit fragments or VM arguments.
109109

110+
The seed command optionally accepts `--client-models JSON_FILE`. The JSON object
111+
maps service keys to approved model identifiers. Confidential requires exactly
112+
the `local` key; internal requires exactly `local`, `codex` and `anthropic`.
113+
It writes persistent client
114+
defaults for the fixed guest broker at `127.0.0.1:5001`, so commands do not depend
115+
on loading a shell profile. Authentication values in these files are dummy
116+
values; provider credentials stay outside the guest. Confidential seeds write
117+
only Pi defaults. Guest settings provide convenience; host policy enforces access.
118+
110119
## Model services
111120

112121
The local adapter forwards to a fixed model service; it starts no inference

‎sandbox/ai_zones/guest.py‎

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@
6969
MAX_DESCRIPTOR_BYTES = 16 * 1024
7070
HASH_CHUNK_BYTES = 256 * 1024
7171
MAX_PUBKEY_CHARS = 4096
72+
MAX_CLIENT_MODELS_BYTES = 4096
73+
_CLIENT_MODEL_ID = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}\Z")
7274

7375
ED25519_TYPE = b"ssh-ed25519"
7476
OPENSSH_PRIVATE_MAGIC = b"openssh-key-v1\0"
@@ -307,7 +309,7 @@ def _check_ed25519_private_text(text, what):
307309
# seed
308310
# --------------------------------------------------------------------------- #
309311

310-
def build_seed(zone, operator_public_key, ssh_host_private_key, ssh_host_public_key):
312+
def build_seed(zone, operator_public_key, ssh_host_private_key, ssh_host_public_key, *, client_models=None):
311313
"""Return the seed as ``{"user-data": ..., "meta-data": ...}`` text.
312314
313315
The seed is JSON, which is valid YAML, so no YAML library is needed. It
@@ -357,6 +359,11 @@ def build_seed(zone, operator_public_key, ssh_host_private_key, ssh_host_public_
357359
"package_reboot_if_required": False,
358360
"final_message": "agent-zone %s seed applied" % zone,
359361
}
362+
if client_models is not None:
363+
client_files, directories = _client_files(zone, client_models)
364+
cloud_config["write_files"].extend(client_files)
365+
cloud_config["runcmd"].insert(0, ["install", "-d", "-m", "0700", "-o", AGENT_USER,
366+
"-g", AGENT_USER, *directories])
360367
user_data = "#cloud-config\n" + json.dumps(cloud_config, indent=2, sort_keys=True) + "\n"
361368
meta_data = json.dumps(
362369
{
@@ -375,7 +382,7 @@ def build_seed(zone, operator_public_key, ssh_host_private_key, ssh_host_public_
375382
return {"user-data": user_data, "meta-data": meta_data}
376383

377384

378-
def write_seed(zone, output, operator_public_key, ssh_host_private_key, ssh_host_public_key):
385+
def write_seed(zone, output, operator_public_key, ssh_host_private_key, ssh_host_public_key, *, client_models=None):
379386
"""Read the key inputs, build the seed and write it into a fresh directory."""
380387
operator_text = _read_regular_file(operator_public_key, "operator-public-key", MAX_KEY_BYTES)
381388
private_text = _read_regular_file(ssh_host_private_key, "ssh-host-private-key", MAX_KEY_BYTES)
@@ -386,6 +393,7 @@ def write_seed(zone, output, operator_public_key, ssh_host_private_key, ssh_host
386393
operator_text.decode("utf-8"),
387394
private_text.decode("utf-8"),
388395
host_public_text.decode("utf-8"),
396+
client_models=client_models,
389397
)
390398
except UnicodeDecodeError as exc:
391399
raise GuestSeedError("key inputs must be UTF-8 text") from exc
@@ -727,6 +735,96 @@ def _seed_files():
727735
"content": text} for path, (text, mode) in files.items()]
728736

729737

738+
def _client_files(zone, models):
739+
"""Guest defaults express a host-approved mapping; they grant no authority."""
740+
keys = {"local"} if zone == "confidential" else {"local", "codex", "anthropic"}
741+
if not isinstance(models, dict) or set(models) != keys:
742+
raise GuestSeedError("client_models must have exactly %s for %s" % (sorted(keys), zone))
743+
if any(not isinstance(value, str) or not _CLIENT_MODEL_ID.fullmatch(value)
744+
for value in models.values()):
745+
raise GuestSeedError("client model IDs must be 1..256 inert ASCII model-ID characters")
746+
747+
files = []
748+
directories = ["/home/agent/.pi", "/home/agent/.pi/agent", "/etc/agent-zone"]
749+
750+
def add(path, content):
751+
if not isinstance(content, str):
752+
content = json.dumps(content, indent=2, sort_keys=True) + "\n"
753+
files.append({"path": path, "owner": "agent:agent", "permissions": "0600",
754+
"defer": True, "content": content})
755+
756+
add("/etc/agent-zone/client-models.json", models)
757+
add("/home/agent/.pi/agent/settings.json", {"defaultProvider": "sandbox-broker",
758+
"defaultModel": models["local"]})
759+
add("/home/agent/.pi/agent/models.json", {"providers": {"sandbox-broker": {
760+
"baseUrl": "http://127.0.0.1:5001/v1", "api": "openai-completions",
761+
"apiKey": "dummy-local-capability", "authHeader": True,
762+
"models": [{"id": models["local"], "name": "Approved local model", "input": ["text"],
763+
"reasoning": False, "contextWindow": 16384, "maxTokens": 2048,
764+
"compat": {"supportsStore": True, "supportsUsageInStreaming": True,
765+
"maxTokensField": "max_tokens", "supportsStrictMode": False}}]}}})
766+
environment = {"PI_OFFLINE": "1"}
767+
if zone == "internal":
768+
directories.extend(["/home/agent/.codex", "/home/agent/.claude"])
769+
model = models["codex"]
770+
catalog = {"models": [{"slug": model, "display_name": "Approved Codex model",
771+
"description": "Host broker model", "base_instructions": "You are a coding assistant.",
772+
"shell_type": "shell_command", "visibility": "list", "supported_in_api": True,
773+
"priority": 0, "supported_reasoning_levels": [], "support_verbosity": False,
774+
"supports_reasoning_summaries": True, "supports_parallel_tool_calls": True,
775+
"apply_patch_tool_type": "freeform", "context_window": 8192,
776+
"truncation_policy": {"mode": "tokens", "limit": 1000},
777+
"input_modalities": ["text"], "experimental_supported_tools": []}]}
778+
add("/home/agent/.codex/models.json", catalog)
779+
# The pinned custom Responses provider already sends store=false.
780+
add("/home/agent/.codex/config.toml", 'model = %s\n' % json.dumps(model) + '''model_provider = "sandbox-broker"
781+
model_catalog_json = "/home/agent/.codex/models.json"
782+
web_search = "disabled"
783+
check_for_update_on_startup = false
784+
[analytics]
785+
enabled = false
786+
[features]
787+
multi_agent = false
788+
multi_agent_v2 = false
789+
goals = false
790+
plugins = false
791+
apps = false
792+
hooks = false
793+
view_image = false
794+
shell_snapshot = false
795+
[model_providers.sandbox-broker]
796+
name = "Guest sandbox broker"
797+
base_url = "http://127.0.0.1:5001/codex/v1"
798+
wire_api = "responses"
799+
experimental_bearer_token = "dummy-local-capability"
800+
requires_openai_auth = false
801+
supports_websockets = false
802+
request_max_retries = 0
803+
stream_max_retries = 0
804+
[model_providers.sandbox-broker.capabilities]
805+
remote_compaction = "unsupported"
806+
external_web_access = false
807+
''')
808+
anthropic = {"ANTHROPIC_BASE_URL": "http://127.0.0.1:5001/claude",
809+
"ANTHROPIC_AUTH_TOKEN": "dummy-local-capability",
810+
"ANTHROPIC_MODEL": models["anthropic"],
811+
"ANTHROPIC_SMALL_FAST_MODEL": models["anthropic"],
812+
"ANTHROPIC_DEFAULT_OPUS_MODEL": models["anthropic"],
813+
"ANTHROPIC_DEFAULT_SONNET_MODEL": models["anthropic"],
814+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": models["anthropic"],
815+
"CLAUDE_CODE_SUBAGENT_MODEL": models["anthropic"],
816+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
817+
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1"}
818+
add("/home/agent/.claude/settings.json", {"model": models["anthropic"], "env": anthropic,
819+
"permissions": {"deny": ["WebFetch", "WebSearch"]}, "enableAllProjectMcpServers": False})
820+
environment.update(anthropic)
821+
# Fixed literals and restricted ASCII IDs are safe as shell single quotes.
822+
files.append({"path": "/etc/profile.d/agent-zone-clients.sh", "owner": "root:root",
823+
"permissions": "0644", "content": "".join(
824+
"export %s='%s'\n" % (name, value) for name, value in sorted(environment.items()))})
825+
return files, directories
826+
827+
730828
def write_relay(output):
731829
"""Write the relay program and its units into a private staging directory."""
732830
directory = _prepare_output_dir(output)
@@ -877,6 +975,7 @@ def _parser():
877975
seed.add_argument("--operator-public-key", required=True)
878976
seed.add_argument("--ssh-host-private-key", required=True)
879977
seed.add_argument("--ssh-host-public-key", required=True)
978+
seed.add_argument("--client-models", help="bounded JSON model-ID mapping for the selected zone")
880979

881980
relay = subcommands.add_parser("relay", help="write the guest relay program and units")
882981
relay.add_argument("--output", required=True)
@@ -891,12 +990,22 @@ def main(argv=None):
891990
arguments = _parser().parse_args(argv)
892991
try:
893992
if arguments.command == "seed":
993+
client_models = None
994+
if arguments.client_models is not None:
995+
try:
996+
text = _read_regular_file(arguments.client_models, "client-models", MAX_CLIENT_MODELS_BYTES)
997+
client_models = json.loads(text.decode("utf-8"), object_pairs_hook=_no_duplicate_keys)
998+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
999+
raise GuestSeedError("client-models must be UTF-8 JSON") from exc
1000+
if client_models is None:
1001+
raise GuestSeedError("client-models file must contain a model-ID mapping")
8941002
directory, written = write_seed(
8951003
arguments.zone,
8961004
arguments.output,
8971005
arguments.operator_public_key,
8981006
arguments.ssh_host_private_key,
8991007
arguments.ssh_host_public_key,
1008+
client_models=client_models,
9001009
)
9011010
print(json.dumps({"seed_dir": directory, "written": written}, sort_keys=True))
9021011
return 0

0 commit comments

Comments
 (0)