6969MAX_DESCRIPTOR_BYTES = 16 * 1024
7070HASH_CHUNK_BYTES = 256 * 1024
7171MAX_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
7375ED25519_TYPE = b"ssh-ed25519"
7476OPENSSH_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+
730828def 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