Coverage for cli / autopilot.py: 100.00%
430 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""Autopilot: launch a configured Claude Code, Codex, or OpenCode session against GCO.
3``gco autopilot`` defaults to Claude Code for backward compatibility and can
4select Codex with ``--engine codex`` (``GCO_AUTOPILOT_ENGINE=codex``) or
5OpenCode with ``--engine opencode`` (``GCO_AUTOPILOT_ENGINE=opencode``). All
6three engines use the caller's AWS credentials, GCO's canonical Bedrock
7defaults, the GCO MCP server, and the recommended companion MCP servers.
8Claude Code keeps its generated JSON config plus ``--strict-mcp-config``
9behavior. Codex uses a generated TOML config and skills inside GCO's isolated
10``~/.gco/autopilot/codex`` home, leaving personal ``~/.codex`` state alone.
11OpenCode reads a generated ``opencode.json`` under ``~/.gco/autopilot/opencode``
12through ``OPENCODE_CONFIG`` with project-level configuration disabled, so the
13launch plan wins over any per-repository ``opencode.json``.
15None of the CLIs is baked into the development container. Autopilot detects
16the selected engine's binary and offers to install its exact npm pin lazily.
18Scanner contract (``.github/scripts/lib_dependency_scan.sh``):
20* ``extract_claude_code_pin``, ``extract_codex_pin``, and
21 ``extract_opencode_pin`` read :data:`CLAUDE_CODE_VERSION`,
22 :data:`CODEX_VERSION`, and :data:`OPENCODE_VERSION` from this file with
23 regexes — keep all three as single-line, double-quoted assignments.
24* ``extract_companion_mcp_packages`` pairs the ``registry=`` / ``package=``
25 keywords inside each ``CompanionServer(`` block — keep those two fields
26 on their own lines when editing the registry below.
27"""
29from __future__ import annotations
31import json
32import os
33import re
34import shutil
35import subprocess
36import sys
37from dataclasses import dataclass, field
38from enum import StrEnum
39from pathlib import Path
41from gco.bedrock import (
42 get_default_claude_code_model_id,
43 get_default_codex_model_id,
44 get_default_codex_reasoning_effort,
45 get_default_opencode_model_id,
46)
48from . import __version__
50#: Exact Claude Code release installed by ``gco autopilot`` when the
51#: ``claude`` binary is absent. Pinned (never ``latest``) so installs are
52#: reproducible; the monthly deps-scan reports drift against npm.
53CLAUDE_CODE_VERSION = "2.1.293"
55#: npm package that ships the ``claude`` binary.
56CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code"
58#: Exact Codex CLI release installed by the Codex engine.
59#: Keep this literal assignment scanner-friendly like CLAUDE_CODE_VERSION.
60CODEX_VERSION = "0.161.0"
62#: npm package that ships the ``codex`` binary.
63CODEX_PACKAGE = "@openai/codex"
65#: Built-in Codex provider that signs Bedrock Runtime requests and supports
66#: geographic/global cross-Region inference profile IDs.
67CODEX_BEDROCK_PROVIDER = "amazon-bedrock-runtime"
69#: Parallel npx/uvx startup can include a first-use package download. Codex's
70#: generated config gives every curated MCP server one bounded minute.
71CODEX_MCP_STARTUP_TIMEOUT_SECONDS = 60.0
73#: Exact OpenCode CLI release installed by the OpenCode engine.
74#: Keep this literal assignment scanner-friendly like CLAUDE_CODE_VERSION.
75OPENCODE_VERSION = "1.18.35"
77#: npm package that ships the ``opencode`` binary.
78OPENCODE_PACKAGE = "opencode-ai"
80#: OpenCode's built-in Amazon Bedrock provider id. Model selectors take the
81#: ``provider/model`` form, so every Bedrock id is prefixed with this value.
82OPENCODE_BEDROCK_PROVIDER = "amazon-bedrock"
84#: OpenCode's per-server MCP request timeout is expressed in milliseconds and
85#: defaults to five seconds — too short for a first-use ``uvx``/``npx``
86#: download. Give every curated server the same bounded minute Codex gets.
87OPENCODE_MCP_TIMEOUT_MS = 60_000
89#: Engine override environment variable; Claude Code remains the compatibility default.
90_ENGINE_ENV = "GCO_AUTOPILOT_ENGINE"
91_CODEX_MODEL_ENV = "GCO_AUTOPILOT_CODEX_MODEL"
92_OPENCODE_MODEL_ENV = "GCO_AUTOPILOT_OPENCODE_MODEL"
95class AutopilotEngine(StrEnum):
96 """Interactive agent runtimes supported by ``gco autopilot``."""
98 CLAUDE_CODE = "claude-code"
99 CODEX = "codex"
100 OPENCODE = "opencode"
103def resolve_engine(explicit: str | AutopilotEngine | None) -> AutopilotEngine:
104 """Resolve engine flag > environment > backward-compatible Claude default."""
105 if explicit is not None:
106 raw: str | AutopilotEngine = explicit
107 elif _ENGINE_ENV in os.environ:
108 raw = os.environ[_ENGINE_ENV]
109 else:
110 return AutopilotEngine.CLAUDE_CODE
111 if isinstance(raw, AutopilotEngine):
112 return raw
113 candidate = str(raw).strip().lower()
114 try:
115 return AutopilotEngine(candidate)
116 except ValueError as exc:
117 supported = ", ".join(engine.value for engine in AutopilotEngine)
118 raise ValueError(f"Unknown autopilot engine {raw!r}. Choose one of: {supported}.") from exc
121#: Where the generated session MCP config lands. Regenerated on every
122#: launch, so hand edits do not survive — persistent customization belongs
123#: in your own MCP config (see gco_mcp/README.md).
124_CONFIG_DIR_ENV = "GCO_AUTOPILOT_CONFIG_DIR"
125_DEFAULT_CONFIG_DIR = Path.home() / ".gco" / "autopilot"
126_CONFIG_FILENAME = "mcp.json"
127_CODEX_HOME_DIRNAME = "codex"
128_CODEX_CONFIG_FILENAME = "config.toml"
129_CODEX_SKILLS_DIRNAME = "skills"
130_OPENCODE_HOME_DIRNAME = "opencode"
131_OPENCODE_CONFIG_FILENAME = "opencode.json"
133#: JSON schema OpenCode publishes for ``opencode.json``; emitted so editors
134#: validate the generated file and so OpenCode never rewrites it.
135_OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"
137#: AWS named profile the generated OpenCode config pins when the environment
138#: carries no credential source at all. OpenCode's Bedrock loader only
139#: enables the provider when it sees a profile (config or ``AWS_PROFILE``),
140#: static keys, a bearer token, web-identity, or container credentials, so a
141#: plain ``~/.aws/credentials`` ``[default]`` user (or an EC2 instance role
142#: with an empty environment) would otherwise have no Bedrock provider at
143#: all. ``default`` is what the SDK resolves anyway when nothing is set.
144_OPENCODE_DEFAULT_AWS_PROFILE = "default"
146#: Environment variables OpenCode's Bedrock loader already treats as a
147#: credential source. When any is present the config must NOT name a
148#: profile: an explicit profile makes the AWS SDK's default chain skip
149#: ``fromEnv`` entirely ("AWS_PROFILE is set, skipping fromEnv provider"),
150#: which would silently discard exported static keys and, worse, fall back
151#: to a ``[default]`` profile that may be a different identity.
152_OPENCODE_AWS_CREDENTIAL_SOURCE_ENV: tuple[str, ...] = (
153 "AWS_PROFILE",
154 "AWS_ACCESS_KEY_ID",
155 "AWS_BEARER_TOKEN_BEDROCK",
156 "AWS_WEB_IDENTITY_TOKEN_FILE",
157 "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
158 "AWS_CONTAINER_CREDENTIALS_FULL_URI",
159)
161#: Bedrock model lines OpenCode's models.dev catalog does not list yet. The
162#: generated config declares the selected model so OpenCode can load it (an
163#: undeclared, uncatalogued model is a hard ``ModelNotFoundError``), and the
164#: metadata lets OpenCode size context compaction and report cost. Keys are
165#: geography-stripped inference-profile ids, matching the allowlist discipline
166#: in :mod:`gco.bedrock`; values follow OpenCode's config model schema. The
167#: Kimi K3 entry mirrors the Amazon Bedrock model card (1M-token context,
168#: native vision, always-on reasoning, tool calling, Global CRIS pricing per
169#: million tokens).
170_OPENCODE_BEDROCK_MODEL_METADATA: dict[str, dict[str, object]] = {
171 "moonshotai.kimi-k3": {
172 "name": "Kimi K3",
173 "family": "kimi-k3",
174 "attachment": True,
175 "reasoning": True,
176 "tool_call": True,
177 "temperature": False,
178 "modalities": {"input": ["text", "image"], "output": ["text"]},
179 "limit": {"context": 1_048_576, "output": 131_072},
180 "cost": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 3.75},
181 "release_date": "2026-09-18",
182 },
183}
185#: Geography prefixes Bedrock uses for cross-Region inference profiles.
186_OPENCODE_MODEL_GEO_PREFIX_RE = re.compile(r"^(?:global|us|us-gov|eu|apac|jp|au|ca|sa|il|mx)\.")
188#: Model override environment variable (the ``--model`` flag wins over it).
189_MODEL_ENV = "GCO_AUTOPILOT_MODEL"
191#: Optional Bedrock model for Claude Code's background/fast tasks. Left
192#: unset by default: the right haiku-class profile depends on what the
193#: account has enabled, and Claude Code degrades gracefully without it.
194_SMALL_FAST_MODEL_ENV = "GCO_AUTOPILOT_SMALL_FAST_MODEL"
196#: Placeholder in companion ``args`` replaced with the launch directory.
197_WORKSPACE_PLACEHOLDER = "{workspace}"
199#: Colon-separated plugin dirs/zips always loaded into autopilot sessions
200#: (merged with per-launch ``--plugin`` flags).
201_PLUGIN_DIRS_ENV = "GCO_AUTOPILOT_PLUGIN_DIRS"
203#: Name of the synthetic session plugin that packages loose ``--skills`` /
204#: ``--agents`` directories so Claude Code can load them.
205_IMPORTS_PLUGIN_NAME = "gco-autopilot-imports"
207#: Read-only command allowlist for the shell companion. Deliberately tight —
208#: no ``rm``, no ``git`` — matching the guidance in gco_mcp/README.md.
209_SHELL_ALLOW_COMMANDS = "ls,cat,pwd,grep,wc,touch,find"
212@dataclass(frozen=True)
213class CompanionServer:
214 """One recommended companion MCP server from ``gco_mcp/README.md``.
216 ``registry`` + ``package`` identify the distribution (``npm`` or
217 ``pypi``) for the deps-scan liveness check; ``command`` + ``args`` +
218 ``env`` are the stdio launch recipe written into the session config.
219 """
221 name: str
222 registry: str
223 package: str
224 command: str
225 args: tuple[str, ...]
226 env: dict[str, str] = field(default_factory=dict)
229#: The companion MCP servers wired into every autopilot session, one entry
230#: per row of the "Recommended Companion MCP Servers" tables in
231#: ``gco_mcp/README.md``. ``tests/test_cli_autopilot.py`` enforces that the
232#: two stay in lockstep, and the monthly deps-scan verifies each package is
233#: still published (and not deprecated/yanked) on its registry.
234#:
235#: The EKS server is configured read-only on purpose: an auto-generated
236#: agent session should not silently hold cluster write access. Add
237#: ``--allow-write`` / ``--allow-sensitive-data-access`` in your own MCP
238#: config if you want the mutating tools.
239COMPANION_MCP_SERVERS: tuple[CompanionServer, ...] = (
240 CompanionServer(
241 name="aws-docs",
242 registry="pypi",
243 package="awslabs.aws-documentation-mcp-server",
244 command="uvx",
245 args=("awslabs.aws-documentation-mcp-server@latest",),
246 env={"FASTMCP_LOG_LEVEL": "ERROR"},
247 ),
248 CompanionServer(
249 name="aws-pricing",
250 registry="pypi",
251 package="awslabs.aws-pricing-mcp-server",
252 command="uvx",
253 args=("awslabs.aws-pricing-mcp-server@latest",),
254 env={"FASTMCP_LOG_LEVEL": "ERROR"},
255 ),
256 CompanionServer(
257 name="eks",
258 registry="pypi",
259 package="awslabs.eks-mcp-server",
260 command="uvx",
261 args=("awslabs.eks-mcp-server@latest",),
262 env={"FASTMCP_LOG_LEVEL": "ERROR"},
263 ),
264 CompanionServer(
265 name="filesystem",
266 registry="npm",
267 package="@modelcontextprotocol/server-filesystem",
268 command="npx",
269 args=("-y", "@modelcontextprotocol/server-filesystem", _WORKSPACE_PLACEHOLDER),
270 ),
271 CompanionServer(
272 name="ddg-search",
273 registry="pypi",
274 package="duckduckgo-mcp-server",
275 command="uvx",
276 args=("duckduckgo-mcp-server",),
277 ),
278 CompanionServer(
279 name="deepwiki",
280 registry="npm",
281 package="mcp-deepwiki",
282 command="npx",
283 args=("-y", "mcp-deepwiki@latest"),
284 ),
285 CompanionServer(
286 name="playwright",
287 registry="npm",
288 package="@playwright/mcp",
289 command="npx",
290 args=("-y", "@playwright/mcp@latest"),
291 ),
292 CompanionServer(
293 name="sequential-thinking",
294 registry="npm",
295 package="@modelcontextprotocol/server-sequential-thinking",
296 command="npx",
297 args=("-y", "@modelcontextprotocol/server-sequential-thinking"),
298 ),
299 CompanionServer(
300 name="inner-monologue",
301 registry="npm",
302 package="inner-monologue-mcp",
303 command="npx",
304 args=("-y", "inner-monologue-mcp"),
305 ),
306 CompanionServer(
307 name="memory",
308 registry="npm",
309 package="@modelcontextprotocol/server-memory",
310 command="npx",
311 args=("-y", "@modelcontextprotocol/server-memory"),
312 ),
313 CompanionServer(
314 name="mcp-tasks",
315 registry="npm",
316 package="mcp-tasks",
317 command="npx",
318 args=("-y", "mcp-tasks"),
319 ),
320 CompanionServer(
321 name="shell",
322 registry="pypi",
323 package="mcp-shell-server",
324 command="uvx",
325 args=("mcp-shell-server",),
326 env={"ALLOW_COMMANDS": _SHELL_ALLOW_COMMANDS},
327 ),
328)
331def _source_checkout_root(candidate: Path | None = None) -> Path | None:
332 """Return the GCO checkout root when autopilot runs from source.
334 Mirrors the marker discipline in :mod:`gco.bedrock`: only the checkout
335 that owns *this* file counts (``candidate`` exists for tests) — the
336 current working directory is deliberately ignored so an unrelated
337 project cannot redirect which MCP server code the session runs.
338 """
339 root = candidate if candidate is not None else Path(__file__).resolve().parent.parent
340 markers = (root / "app.py", root / "pyproject.toml", root / "gco_mcp" / "run_mcp.py")
341 if all(marker.is_file() for marker in markers):
342 return root
343 return None
346#: Every feature flag the GCO MCP server understands: the umbrella flag
347#: first (a perfectly reasonable thing to pass to ``--enable``), then the
348#: per-tool flags. This mirrors ``gco_mcp/feature_flags.py`` rather than
349#: importing it — the CLI must not depend on the MCP package at runtime
350#: (mypy also maps the PEP 420 namespace file under two module names when
351#: both trees are checked together). ``tests/test_cli_autopilot.py`` holds
352#: the two registries in lockstep, so drift fails the PR that introduces it.
353_KNOWN_GCO_MCP_FLAGS: tuple[str, ...] = (
354 "GCO_ENABLE_ALL_TOOLS",
355 "GCO_ENABLE_CAPACITY_PURCHASE",
356 "GCO_ENABLE_MODEL_UPLOAD",
357 "GCO_ENABLE_IMAGE_PUBLISH",
358 "GCO_ENABLE_INFRASTRUCTURE_DEPLOY",
359 "GCO_ENABLE_INFRASTRUCTURE_DESTROY",
360 "GCO_ENABLE_DESTRUCTIVE_OPERATIONS",
361 "GCO_ENABLE_MISSION",
362 "GCO_ENABLE_LOCAL_METRICS",
363 "GCO_ENABLE_LOCAL_STORAGE_SYNC",
364 "GCO_ENABLE_SEMANTIC_PROGRESS",
365 "GCO_ENABLE_CONFIG_MANAGEMENT",
366 "GCO_ENABLE_SWARM",
367)
370def known_gco_mcp_flags() -> tuple[str, ...]:
371 """Return every feature flag the GCO MCP server understands."""
372 return _KNOWN_GCO_MCP_FLAGS
375def resolve_mcp_flags(enable: tuple[str, ...]) -> dict[str, str]:
376 """Translate ``--enable`` values into env vars for the gco MCP server.
378 Accepts either the full env-var form (``GCO_ENABLE_MISSION``) or the
379 bare suffix (``mission``, ``all-tools``, ``ALL_TOOLS``) and normalizes
380 to the canonical name. Unknown flags raise ``ValueError`` listing the
381 valid set — a typo should fail loudly at launch, not silently launch a
382 session missing the tools the caller asked for.
383 """
384 known = known_gco_mcp_flags()
385 by_name = {flag: flag for flag in known}
386 by_suffix = {flag.removeprefix("GCO_ENABLE_"): flag for flag in known}
388 resolved: dict[str, str] = {}
389 for raw in enable:
390 candidate = raw.strip().upper().replace("-", "_")
391 flag = by_name.get(candidate) or by_suffix.get(candidate)
392 if flag is None:
393 suffixes = ", ".join(sorted(s.lower().replace("_", "-") for s in by_suffix))
394 raise ValueError(
395 f"Unknown GCO MCP feature flag {raw!r}. Valid flags: {suffixes} "
396 "(or their full GCO_ENABLE_* names)."
397 )
398 resolved[flag] = "true"
399 return resolved
402def _gco_server_entry(mcp_env: dict[str, str] | None = None) -> dict[str, object]:
403 """Build the ``gco`` MCP server entry for the session config.
405 From a source checkout the server runs straight off the working tree
406 (``python3 gco_mcp/run_mcp.py``) so local MCP changes are live. From an
407 installed ``gco-cli`` it runs the release tag matching ``__version__``
408 via ``uvx`` — the same no-clone form gco_mcp/README.md documents.
410 ``mcp_env`` carries feature flags (``GCO_ENABLE_*``) and any other
411 server environment (for example ``GCO_MCP_TOOL_SEARCH``) into the
412 server process.
413 """
414 checkout = _source_checkout_root()
415 entry: dict[str, object]
416 if checkout is not None:
417 entry = {
418 "command": sys.executable or "python3",
419 "args": [str(checkout / "gco_mcp" / "run_mcp.py")],
420 }
421 else:
422 entry = {
423 "command": "uvx",
424 "args": [
425 "--python",
426 "3.14",
427 "--from",
428 "git+https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git"
429 f"@v{__version__}",
430 "gco-mcp",
431 ],
432 }
433 if mcp_env:
434 entry["env"] = dict(sorted(mcp_env.items()))
435 return entry
438def build_mcp_config(
439 workspace: Path,
440 include_companions: bool = True,
441 gco_mcp_env: dict[str, str] | None = None,
442) -> dict[str, dict[str, dict[str, object]]]:
443 """Return the session ``mcpServers`` config for Claude Code.
445 ``workspace`` replaces the ``{workspace}`` placeholder in companion
446 args (the filesystem server's root), so file access is scoped to the
447 directory autopilot was launched from. ``gco_mcp_env`` is applied to
448 the gco server entry only — feature flags gate GCO tools, not the
449 companions.
450 """
451 servers: dict[str, dict[str, object]] = {"gco": _gco_server_entry(gco_mcp_env)}
452 if include_companions:
453 for companion in COMPANION_MCP_SERVERS:
454 entry: dict[str, object] = {
455 "command": companion.command,
456 "args": [
457 str(workspace) if arg == _WORKSPACE_PLACEHOLDER else arg
458 for arg in companion.args
459 ],
460 }
461 if companion.env:
462 entry["env"] = dict(companion.env)
463 servers[companion.name] = entry
464 return {"mcpServers": servers}
467def config_path() -> Path:
468 """Return the on-disk location of the generated session MCP config."""
469 override = os.environ.get(_CONFIG_DIR_ENV)
470 directory = Path(override).expanduser() if override else _DEFAULT_CONFIG_DIR
471 return directory / _CONFIG_FILENAME
474def write_mcp_config(config: dict[str, dict[str, dict[str, object]]]) -> Path:
475 """Write the session MCP config and return its path."""
476 path = config_path()
477 path.parent.mkdir(parents=True, exist_ok=True)
478 path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
479 return path
482def codex_home() -> Path:
483 """Return GCO's isolated, persistent Codex home directory."""
484 return config_path().parent / _CODEX_HOME_DIRNAME
487def codex_config_path() -> Path:
488 """Return the generated Codex config path under the isolated home."""
489 return codex_home() / _CODEX_CONFIG_FILENAME
492def _toml_string(value: str) -> str:
493 """Encode one TOML basic string using JSON's compatible escaping."""
494 return json.dumps(value, ensure_ascii=False)
497def _toml_array(values: list[str]) -> str:
498 return "[" + ", ".join(_toml_string(value) for value in values) + "]"
501def build_codex_config_toml(
502 mcp_config: dict[str, dict[str, dict[str, object]]],
503 *,
504 model: str,
505 region: str,
506 reasoning_effort: str | None,
507) -> str:
508 """Render a complete isolated Codex config with Bedrock and MCP servers.
510 ``model_reasoning_effort`` is emitted only for the canonical GCO model.
511 Explicit CLI/environment model overrides must use the selected model's own
512 defaults instead of inheriting a potentially incompatible effort value.
513 """
514 lines = [
515 f"model = {_toml_string(model)}",
516 f"model_provider = {_toml_string(CODEX_BEDROCK_PROVIDER)}",
517 ]
518 if reasoning_effort is not None:
519 lines.append(f"model_reasoning_effort = {_toml_string(reasoning_effort)}")
520 lines.extend(
521 [
522 "check_for_update_on_startup = false",
523 "",
524 f"[model_providers.{CODEX_BEDROCK_PROVIDER}]",
525 'wire_api = "responses"',
526 "",
527 f"[model_providers.{CODEX_BEDROCK_PROVIDER}.aws]",
528 f"region = {_toml_string(region)}",
529 ]
530 )
531 servers = mcp_config["mcpServers"]
532 for name in sorted(servers):
533 entry = servers[name]
534 command = entry.get("command")
535 args = entry.get("args", [])
536 if not isinstance(command, str) or not command:
537 raise ValueError(f"Codex MCP server {name!r} has no command")
538 if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
539 raise ValueError(f"Codex MCP server {name!r} args must be strings")
540 table_name = _toml_string(name)
541 lines.extend(
542 [
543 "",
544 f"[mcp_servers.{table_name}]",
545 f"command = {_toml_string(command)}",
546 f"args = {_toml_array(args)}",
547 "enabled = true",
548 f"startup_timeout_sec = {CODEX_MCP_STARTUP_TIMEOUT_SECONDS}",
549 ]
550 )
551 environment = entry.get("env")
552 if environment:
553 if not isinstance(environment, dict) or not all(
554 isinstance(key, str) and isinstance(value, str)
555 for key, value in environment.items()
556 ):
557 raise ValueError(f"Codex MCP server {name!r} env must contain strings")
558 lines.extend(["", f"[mcp_servers.{table_name}.env]"])
559 for key, value in sorted(environment.items()):
560 lines.append(f"{_toml_string(key)} = {_toml_string(value)}")
561 return "\n".join(lines) + "\n"
564def write_codex_config(content: str) -> Path:
565 """Write the generated Codex configuration inside isolated CODEX_HOME."""
566 path = codex_config_path()
567 path.parent.mkdir(parents=True, exist_ok=True)
568 path.write_text(content, encoding="utf-8")
569 return path
572def opencode_home() -> Path:
573 """Return GCO's persistent OpenCode directory under the autopilot config dir."""
574 return config_path().parent / _OPENCODE_HOME_DIRNAME
577def opencode_config_path() -> Path:
578 """Return the generated ``opencode.json`` path GCO hands to ``OPENCODE_CONFIG``."""
579 return opencode_home() / _OPENCODE_CONFIG_FILENAME
582def opencode_model_selector(model: str) -> str:
583 """Return OpenCode's ``provider/model`` selector for a Bedrock model id."""
584 return f"{OPENCODE_BEDROCK_PROVIDER}/{model}"
587def opencode_aws_profile() -> str | None:
588 """Return the AWS named profile the generated OpenCode config pins, if any.
590 When the environment already carries a credential source OpenCode
591 recognises (see :data:`_OPENCODE_AWS_CREDENTIAL_SOURCE_ENV`), nothing is
592 pinned and the SDK's standard default chain runs untouched — static keys,
593 ``AWS_PROFILE``, web-identity and container credentials all keep working
594 exactly as they do for the AWS CLI. Only a completely empty environment
595 names ``default`` (see :data:`_OPENCODE_DEFAULT_AWS_PROFILE`) so the
596 provider loads for ``~/.aws`` and instance-role users.
597 """
598 if any(os.environ.get(name, "").strip() for name in _OPENCODE_AWS_CREDENTIAL_SOURCE_ENV):
599 return None
600 return _OPENCODE_DEFAULT_AWS_PROFILE
603def opencode_model_declaration(model: str) -> dict[str, object]:
604 """Return the ``provider.amazon-bedrock.models`` entry for one model id.
606 Every selected model is declared so a brand-new inference profile that
607 OpenCode's models.dev catalog has not picked up yet still launches instead
608 of failing with ``ModelNotFoundError``. Catalogued models get an empty
609 declaration, which OpenCode merges over its own entry field by field, so
610 nothing changes for them. Lines listed in
611 :data:`_OPENCODE_BEDROCK_MODEL_METADATA` carry their full metadata, with
612 the geography folded into the display name the way models.dev does.
613 """
614 base = _OPENCODE_MODEL_GEO_PREFIX_RE.sub("", model)
615 metadata = _OPENCODE_BEDROCK_MODEL_METADATA.get(base)
616 if metadata is None:
617 return {}
618 declaration: dict[str, object] = json.loads(json.dumps(metadata))
619 geography = model[: len(model) - len(base)].rstrip(".")
620 if geography:
621 label = geography.capitalize() if geography == "global" else geography.upper()
622 declaration["name"] = f"{metadata['name']} ({label})"
623 return declaration
626def build_opencode_config(
627 mcp_config: dict[str, dict[str, dict[str, object]]],
628 *,
629 model: str,
630 region: str,
631 small_model: str | None = None,
632 skills_paths: tuple[Path, ...] = (),
633) -> dict[str, object]:
634 """Render the complete ``opencode.json`` for an Autopilot session.
636 OpenCode merges this file above the user's global config (and GCO
637 disables project-level config for the session), so every key here is
638 authoritative: the Bedrock provider and model, the update and sharing
639 policy, the MCP server set, and the ``ask`` permission floor for edits and
640 shell commands (OpenCode's native default is allow-all; pass ``-- --auto``
641 to opt back in for one session). MCP entries follow OpenCode's ``local``
642 shape: ``command`` is a single argv list and the timeout is in
643 milliseconds.
645 ``small_model`` is always pinned. Left unset, OpenCode picks a "cheaper"
646 catalog model for title generation on its own — on Bedrock that is a
647 Claude Haiku profile, so every Kimi session would silently invoke an
648 Anthropic model the caller never chose (and that needs the one-time
649 Anthropic first-time-use form). The session model is the default; the
650 fast-model option overrides it.
651 """
652 resolved_small_model = small_model or model
653 models: dict[str, object] = {model: opencode_model_declaration(model)}
654 models.setdefault(resolved_small_model, opencode_model_declaration(resolved_small_model))
655 options: dict[str, object] = {"region": region}
656 profile = opencode_aws_profile()
657 if profile is not None:
658 options["profile"] = profile
659 provider: dict[str, object] = {"options": options, "models": models}
660 config: dict[str, object] = {
661 "$schema": _OPENCODE_CONFIG_SCHEMA,
662 "model": opencode_model_selector(model),
663 "small_model": opencode_model_selector(resolved_small_model),
664 "autoupdate": False,
665 "share": "disabled",
666 "permission": {"edit": "ask", "bash": "ask"},
667 "provider": {OPENCODE_BEDROCK_PROVIDER: provider},
668 }
669 servers = mcp_config["mcpServers"]
670 rendered: dict[str, object] = {}
671 for name in sorted(servers):
672 entry = servers[name]
673 command = entry.get("command")
674 args = entry.get("args", [])
675 if not isinstance(command, str) or not command:
676 raise ValueError(f"OpenCode MCP server {name!r} has no command")
677 if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
678 raise ValueError(f"OpenCode MCP server {name!r} args must be strings")
679 server: dict[str, object] = {
680 "type": "local",
681 "command": [command, *args],
682 "enabled": True,
683 "timeout": OPENCODE_MCP_TIMEOUT_MS,
684 }
685 environment = entry.get("env")
686 if environment:
687 if not isinstance(environment, dict) or not all(
688 isinstance(key, str) and isinstance(value, str)
689 for key, value in environment.items()
690 ):
691 raise ValueError(f"OpenCode MCP server {name!r} env must contain strings")
692 server["environment"] = dict(sorted(environment.items()))
693 rendered[name] = server
694 config["mcp"] = rendered
695 if skills_paths:
696 config["skills"] = {"paths": [str(path) for path in skills_paths]}
697 return config
700def write_opencode_config(config: dict[str, object]) -> Path:
701 """Write the generated ``opencode.json`` and return its path."""
702 path = opencode_config_path()
703 path.parent.mkdir(parents=True, exist_ok=True)
704 path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
705 return path
708def resolve_opencode_skills_paths(skills_dirs: tuple[str, ...]) -> tuple[Path, ...]:
709 """Validate ``--skills`` sources and return the absolute paths OpenCode scans.
711 OpenCode discovers ``**/SKILL.md`` under each ``skills.paths`` entry, so
712 the validated source directories are referenced in place — nothing is
713 copied, and edits to a skill are live on the next launch.
714 """
715 validate_imports(skills_dirs, ())
716 resolved: list[Path] = []
717 seen: set[Path] = set()
718 for item in skills_dirs:
719 path = Path(item).expanduser().resolve()
720 if path not in seen:
721 seen.add(path)
722 resolved.append(path)
723 return tuple(resolved)
726def _selected_model_override(
727 explicit: str | None,
728 env_names: tuple[str, ...],
729) -> tuple[str | None, str | None]:
730 """Return the highest-precedence model override and its user-facing source.
732 Presence, not truthiness, selects a source so a blank high-precedence value
733 fails closed instead of silently falling through to another model.
734 """
735 if explicit is not None:
736 raw_value = explicit
737 source = "--model"
738 else:
739 for env_name in env_names:
740 if env_name in os.environ:
741 raw_value = os.environ[env_name]
742 source = env_name
743 break
744 else:
745 return None, None
746 value = raw_value.strip()
747 if not value:
748 raise ValueError(f"{source} must be a non-empty Bedrock model id")
749 return value, source
752def resolve_model(explicit: str | None) -> tuple[str, list[str]]:
753 """Resolve the Claude Bedrock model with strict override validation.
755 Precedence: ``--model`` flag > ``GCO_AUTOPILOT_MODEL`` env > the
756 ``cdk.json`` Claude Code default
757 (``context.bedrock.claude_code_default_model_id``, deliberately separate
758 from the advisory default Mission and the capacity advisor share). The
759 result is advisory-validated only: Bedrock ids for Claude contain
760 ``anthropic``/``claude``, but application inference-profile ARNs are
761 opaque, so an unfamiliar id produces a warning rather than a refusal.
762 """
763 warnings: list[str] = []
764 override, _source = _selected_model_override(explicit, (_MODEL_ENV,))
765 model = override if override is not None else get_default_claude_code_model_id()
766 lowered = model.lower()
767 if "anthropic" not in lowered and "claude" not in lowered:
768 warnings.append(
769 f"Model id {model!r} does not look like a Claude model on Bedrock. "
770 "Claude Code is tuned for Claude models; continuing anyway."
771 )
772 return model, warnings
775def resolve_codex_model(explicit: str | None) -> tuple[str, list[str]]:
776 """Resolve Codex model: flag > Codex env > generic env > cdk.json."""
777 warnings: list[str] = []
778 override, _source = _selected_model_override(
779 explicit,
780 (_CODEX_MODEL_ENV, _MODEL_ENV),
781 )
782 model = override if override is not None else get_default_codex_model_id()
783 lowered = model.lower()
784 if "openai" not in lowered and "gpt" not in lowered:
785 warnings.append(
786 f"Model id {model!r} does not look like an OpenAI model on Bedrock; "
787 "continuing with the explicit override."
788 )
789 return model, warnings
792def resolve_codex_reasoning_effort(explicit_model: str | None = None) -> str | None:
793 """Return canonical Codex effort only when no model override was selected."""
794 override, _source = _selected_model_override(
795 explicit_model,
796 (_CODEX_MODEL_ENV, _MODEL_ENV),
797 )
798 if override is not None:
799 return None
800 return get_default_codex_reasoning_effort()
803def resolve_opencode_model(explicit: str | None) -> tuple[str, list[str]]:
804 """Resolve OpenCode model: flag > OpenCode env > generic env > cdk.json.
806 OpenCode is vendor-neutral, so unlike the Claude and Codex resolvers no
807 model family is preferred. The advisory check catches the one likely
808 mistake instead: pasting a vendor or models.dev name (``kimi-k3``) rather
809 than the Bedrock id. Bedrock ids and inference profiles are dotted
810 (``provider.model`` or ``geo.provider.model``) and application inference
811 profiles are ARNs; anything else is almost certainly not addressable.
812 """
813 warnings: list[str] = []
814 override, _source = _selected_model_override(
815 explicit,
816 (_OPENCODE_MODEL_ENV, _MODEL_ENV),
817 )
818 model = override if override is not None else get_default_opencode_model_id()
819 if "." not in model and not model.startswith("arn:"):
820 warnings.append(
821 f"Model id {model!r} does not look like a Bedrock model id or inference "
822 "profile (expected provider.model, geo.provider.model, or an ARN); "
823 "continuing anyway."
824 )
825 return model, warnings
828def resolve_small_fast_model(explicit: str | None) -> str | None:
829 """Resolve Claude's optional fast model, rejecting configured blank values."""
830 if explicit is not None:
831 raw_value = explicit
832 source = "--small-fast-model"
833 elif _SMALL_FAST_MODEL_ENV in os.environ:
834 raw_value = os.environ[_SMALL_FAST_MODEL_ENV]
835 source = _SMALL_FAST_MODEL_ENV
836 else:
837 return None
838 value = raw_value.strip()
839 if not value:
840 raise ValueError(f"{source} must be a non-empty Bedrock model id")
841 return value
844def build_claude_env(
845 model: str,
846 region: str,
847 small_fast_model: str | None = None,
848) -> dict[str, str]:
849 """Return the environment for the Claude Code process.
851 Starts from the caller's environment so AWS credentials, profiles, and
852 proxies pass through untouched. An ``AWS_REGION`` already set by the
853 caller wins over the GCO-configured region — least surprise for anyone
854 juggling AWS environments — and ``ANTHROPIC_SMALL_FAST_MODEL`` is only
855 set when a background model was explicitly chosen.
856 """
857 env = dict(os.environ)
858 env["CLAUDE_CODE_USE_BEDROCK"] = "1"
859 env["DISABLE_AUTOUPDATER"] = "1"
860 env["ANTHROPIC_MODEL"] = model
861 env.setdefault("AWS_REGION", region)
862 if small_fast_model:
863 env["ANTHROPIC_SMALL_FAST_MODEL"] = small_fast_model
864 return env
867def build_codex_env(region: str) -> dict[str, str]:
868 """Return an isolated Codex environment while preserving AWS credentials."""
869 env = dict(os.environ)
870 env.setdefault("AWS_REGION", region)
871 env["CODEX_HOME"] = str(codex_home())
872 return env
875def build_opencode_env(region: str) -> dict[str, str]:
876 """Return the OpenCode environment while preserving AWS credentials.
878 ``OPENCODE_CONFIG`` points at the generated ``opencode.json``, which
879 OpenCode merges above the user's global ``~/.config/opencode`` config so
880 GCO's keys win on every conflict. ``OPENCODE_DISABLE_PROJECT_CONFIG`` is
881 the OpenCode equivalent of Codex's untrusted-project policy: no
882 ``opencode.json`` or ``.opencode/`` directory in the launch tree can layer
883 over the plan. Autoupdate is disabled at the environment level too so the
884 pinned binary stays pinned even when the config key is overridden.
885 """
886 env = dict(os.environ)
887 env.setdefault("AWS_REGION", region)
888 env["OPENCODE_CONFIG"] = str(opencode_config_path())
889 env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "1"
890 env["OPENCODE_DISABLE_AUTOUPDATE"] = "1"
891 return env
894def effective_aws_region(default_region: str) -> str:
895 """Resolve the AWS SDK region Codex and its generated config should share."""
896 return os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or default_region
899def find_claude_binary() -> str | None:
900 """Return the resolved ``claude`` executable path, or ``None``."""
901 return shutil.which("claude")
904def find_codex_binary() -> str | None:
905 """Return the resolved ``codex`` executable path, or ``None``."""
906 return shutil.which("codex")
909def find_opencode_binary() -> str | None:
910 """Return the resolved ``opencode`` executable path, or ``None``."""
911 return shutil.which("opencode")
914def plugin_paths_requested(cli_plugins: tuple[str, ...]) -> bool:
915 """Return whether CLI or environment requested a Claude Code plugin."""
916 if cli_plugins:
917 return True
918 return any(part.strip() for part in os.environ.get(_PLUGIN_DIRS_ENV, "").split(":"))
921def resolve_plugin_paths(cli_plugins: tuple[str, ...]) -> list[Path]:
922 """Resolve the plugin dirs/zips this session loads, validating each.
924 Merges ``--plugin`` flags with the ``GCO_AUTOPILOT_PLUGIN_DIRS``
925 environment variable (colon-separated, for the "always bring my team's
926 plugin" case). A missing path raises ``ValueError`` — silently launching
927 without the skills someone asked for is the failure mode this guards.
928 """
929 raw: list[str] = list(cli_plugins)
930 env_value = os.environ.get(_PLUGIN_DIRS_ENV, "")
931 raw.extend(part for part in env_value.split(":") if part.strip())
933 resolved: list[Path] = []
934 seen: set[Path] = set()
935 for item in raw:
936 path = Path(item).expanduser()
937 if not path.exists():
938 raise ValueError(f"Plugin path does not exist: {path}")
939 path = path.resolve()
940 if path not in seen:
941 seen.add(path)
942 resolved.append(path)
943 return resolved
946def validate_imports(
947 skills_dirs: tuple[str, ...],
948 agents_dirs: tuple[str, ...],
949) -> None:
950 """Validate ``--skills`` / ``--agents`` sources without staging anything.
952 Shared by the dry-run/plan path (which must not write) and by
953 :func:`stage_imports`. ``skills_dirs`` entries must contain at least one
954 ``*/SKILL.md``; ``agents_dirs`` entries at least one ``*.md``. Anything
955 else raises ``ValueError`` — an empty import is a typo'd path, not a
956 preference.
957 """
958 for kind, sources, marker in (
959 ("skills", skills_dirs, "*/SKILL.md"),
960 ("agents", agents_dirs, "*.md"),
961 ):
962 for source_raw in sources:
963 source = Path(source_raw).expanduser()
964 if not source.is_dir():
965 raise ValueError(f"--{kind} path is not a directory: {source}")
966 if not any(source.glob(marker)):
967 raise ValueError(
968 f"--{kind} directory {source} contains no {marker} — "
969 "check the path (skills are one subdirectory per skill "
970 "with a SKILL.md; agents are *.md files)."
971 )
974def stage_imports(
975 skills_dirs: tuple[str, ...],
976 agents_dirs: tuple[str, ...],
977) -> Path | None:
978 """Package loose skills/agents directories as a session plugin.
980 Claude Code loads skills and agents from ``~/.claude`` and the
981 workspace's ``.claude`` automatically; this exists for everything
982 *else* — a team repo of skills, a scratch directory of agent files —
983 without copying anything into the user's project or personal config.
984 The staged plugin lives next to the generated MCP config, is rebuilt
985 from scratch on every launch (hand edits do not survive), and is
986 handed to claude with ``--plugin-dir``.
988 Sources are validated by :func:`validate_imports` first. Returns the
989 plugin directory, or ``None`` when nothing was imported.
990 """
991 if not skills_dirs and not agents_dirs:
992 return None
993 validate_imports(skills_dirs, agents_dirs)
995 plugin_root = config_path().parent / _IMPORTS_PLUGIN_NAME
996 shutil.rmtree(plugin_root, ignore_errors=True)
997 manifest_dir = plugin_root / ".claude-plugin"
998 manifest_dir.mkdir(parents=True)
999 manifest = {
1000 "name": _IMPORTS_PLUGIN_NAME,
1001 "version": __version__,
1002 "description": (
1003 "Session-scoped skills/agents imported by `gco autopilot "
1004 "--skills/--agents`. Regenerated on every launch."
1005 ),
1006 }
1007 (manifest_dir / "plugin.json").write_text(
1008 json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
1009 )
1011 for kind, sources in (("skills", skills_dirs), ("agents", agents_dirs)):
1012 destination = plugin_root / kind
1013 for source_raw in sources:
1014 source = Path(source_raw).expanduser()
1015 shutil.copytree(source, destination, dirs_exist_ok=True)
1016 return plugin_root
1019def stage_codex_skills(skills_dirs: tuple[str, ...]) -> Path | None:
1020 """Rebuild GCO's isolated Codex skills directory from validated sources."""
1021 destination = codex_home() / _CODEX_SKILLS_DIRNAME
1022 shutil.rmtree(destination, ignore_errors=True)
1023 if not skills_dirs:
1024 return None
1025 validate_imports(skills_dirs, ())
1026 destination.mkdir(parents=True)
1027 for source_raw in skills_dirs:
1028 shutil.copytree(
1029 Path(source_raw).expanduser(),
1030 destination,
1031 dirs_exist_ok=True,
1032 )
1033 return destination
1036def build_plugin_args(plugin_paths: list[Path]) -> tuple[str, ...]:
1037 """Render plugin paths as claude's repeatable ``--plugin-dir`` flags."""
1038 args: list[str] = []
1039 for path in plugin_paths:
1040 args.extend(("--plugin-dir", str(path)))
1041 return tuple(args)
1044#: Where Claude Code keeps per-project conversation transcripts.
1045_CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
1048def _claude_project_dir_name(workspace: Path) -> str:
1049 """Return Claude Code's directory name for a workspace path.
1051 Claude Code names each entry under ``~/.claude/projects`` by replacing
1052 every non-alphanumeric character of the absolute workspace path with
1053 ``-`` (so ``/Users/dev/my_repo`` becomes ``-Users-dev-my-repo``).
1054 """
1055 return re.sub(r"[^A-Za-z0-9]", "-", str(workspace))
1058def has_resumable_session(workspace: Path) -> bool:
1059 """Return whether Claude Code has a previous session for ``workspace``.
1061 Peeks at Claude Code's own transcript store (one ``*.jsonl`` per
1062 conversation). The layout is Claude Code internal, so this check is
1063 deliberately fail-quiet: if the directory scheme ever changes, the
1064 resume prompt silently stops appearing while the explicit
1065 ``--continue`` / ``--resume`` flags — which claude interprets itself —
1066 keep working unchanged.
1067 """
1068 try:
1069 project_dir = _CLAUDE_PROJECTS_DIR / _claude_project_dir_name(workspace)
1070 return any(project_dir.glob("*.jsonl"))
1071 except OSError:
1072 return False
1075def claude_install_command() -> list[str]:
1076 """Return the pinned, reproducible Claude Code install command.
1078 ``--allow-scripts`` names exactly this one package: Claude Code's
1079 postinstall downloads the platform-native binary, and npm >= 12 blocks
1080 lifecycle scripts by default, which would otherwise leave a shim on
1081 PATH that fails with ``Exec format error`` on launch. Older npm (< 12)
1082 accepts and ignores the flag, so one command form works everywhere.
1083 """
1084 return [
1085 "npm",
1086 "install",
1087 "-g",
1088 f"--allow-scripts={CLAUDE_CODE_PACKAGE}",
1089 f"{CLAUDE_CODE_PACKAGE}@{CLAUDE_CODE_VERSION}",
1090 ]
1093def install_claude_code() -> int:
1094 """Install the pinned Claude Code release; return the npm exit code."""
1095 if shutil.which("npm") is None:
1096 return 127
1097 return subprocess.call(claude_install_command())
1100def codex_install_command() -> list[str]:
1101 """Return the exact pinned Codex npm install command."""
1102 return ["npm", "install", "-g", f"{CODEX_PACKAGE}@{CODEX_VERSION}"]
1105def install_codex() -> int:
1106 """Install the pinned Codex release; return the npm exit code."""
1107 if shutil.which("npm") is None:
1108 return 127
1109 return subprocess.call(codex_install_command())
1112def opencode_install_command() -> list[str]:
1113 """Return the pinned, reproducible OpenCode install command.
1115 Like Claude Code, ``opencode-ai`` ships a shim whose postinstall fetches
1116 the platform-native binary; when npm >= 12 blocks lifecycle scripts the
1117 shim on PATH only prints "postinstall script was not run" and exits 1.
1118 ``--allow-scripts`` names exactly this one package and is ignored by older
1119 npm, so one command form works everywhere.
1120 """
1121 return [
1122 "npm",
1123 "install",
1124 "-g",
1125 f"--allow-scripts={OPENCODE_PACKAGE}",
1126 f"{OPENCODE_PACKAGE}@{OPENCODE_VERSION}",
1127 ]
1130def install_opencode() -> int:
1131 """Install the pinned OpenCode release; return the npm exit code."""
1132 if shutil.which("npm") is None:
1133 return 127
1134 return subprocess.call(opencode_install_command())
1137def build_launch_argv(
1138 claude_binary: str,
1139 mcp_config: Path,
1140 extra_args: tuple[str, ...] = (),
1141 resume_args: tuple[str, ...] = (),
1142 plugin_args: tuple[str, ...] = (),
1143) -> list[str]:
1144 """Return the Claude Code argv for a hermetic autopilot session.
1146 ``--strict-mcp-config`` makes the generated config the *only* MCP
1147 config: personal ``~/.claude`` servers and project ``.mcp.json`` files
1148 are ignored, so every autopilot session starts from the same known-good
1149 server set. ``resume_args`` carries claude's native session-resumption
1150 flags (``--continue`` / ``--resume [id]``) when the caller asked to
1151 pick up an earlier conversation; the resumed session still runs under
1152 this launch's MCP config and Bedrock environment. ``plugin_args``
1153 carries the ``--plugin-dir`` flags for session-scoped plugins and the
1154 staged skills/agents imports.
1155 """
1156 return [
1157 claude_binary,
1158 "--mcp-config",
1159 str(mcp_config),
1160 "--strict-mcp-config",
1161 *plugin_args,
1162 *resume_args,
1163 *extra_args,
1164 ]
1167def codex_project_root(workspace: Path) -> Path:
1168 """Return Codex's normalized project root for a run-scoped trust policy.
1170 Codex 0.150.1 loads a trusted project's ``.codex/config.toml`` above its
1171 user config, even when ``CODEX_HOME`` is isolated. Its Git project identity
1172 follows the common Git directory, so linked worktrees resolve to the main
1173 checkout. Outside Git, the launch directory itself is the project root.
1174 """
1175 resolved = workspace.resolve()
1176 try:
1177 result = subprocess.run(
1178 ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
1179 cwd=resolved,
1180 capture_output=True,
1181 text=True,
1182 check=False,
1183 timeout=5,
1184 )
1185 except OSError, subprocess.TimeoutExpired:
1186 return resolved
1187 if result.returncode != 0 or not result.stdout.strip():
1188 return resolved
1189 return Path(result.stdout.strip()).resolve().parent
1192def build_codex_owned_args(
1193 *,
1194 model: str,
1195 region: str,
1196 reasoning_effort: str | None,
1197 workspace: Path,
1198) -> tuple[str, ...]:
1199 """Return session-precedence controls that keep the Codex plan authoritative.
1201 ``CODEX_HOME`` isolates personal user state, but Codex 0.150.1 otherwise
1202 layers trusted project configuration above the generated TOML. Marking the
1203 discovered project root untrusted for this launch disables that project
1204 layer without persisting trust state. Scalar Bedrock/update controls are
1205 repeated at session precedence as defense in depth. Organization-managed
1206 policy remains authoritative by Codex design.
1207 """
1208 project_root = codex_project_root(workspace)
1209 assignments = [
1210 f"model_provider={_toml_string(CODEX_BEDROCK_PROVIDER)}",
1211 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.wire_api={_toml_string('responses')}"),
1212 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.aws.region={_toml_string(region)}"),
1213 "check_for_update_on_startup=false",
1214 (f'projects={{{_toml_string(str(project_root))}={{trust_level="untrusted"}}}}'),
1215 ]
1216 if reasoning_effort is not None:
1217 assignments.insert(
1218 1,
1219 f"model_reasoning_effort={_toml_string(reasoning_effort)}",
1220 )
1222 args = ["--model", model]
1223 for assignment in assignments:
1224 args.extend(("-c", assignment))
1225 return tuple(args)
1228def build_codex_launch_argv(
1229 codex_binary: str,
1230 *,
1231 root_args: tuple[str, ...] = (),
1232 resume_args: tuple[str, ...] = (),
1233 extra_args: tuple[str, ...] = (),
1234) -> list[str]:
1235 """Return Codex argv in native root → resume-selector → passthrough order."""
1236 return [codex_binary, *root_args, *resume_args, *extra_args]
1239def exec_claude(argv: list[str], env: dict[str, str]) -> int:
1240 """Hand the terminal over to Claude Code.
1242 On POSIX the process image is replaced (``execvpe``) so the terminal
1243 *becomes* the session — no wrapper process lingers, signals and TTY
1244 behavior are exactly claude's own. ``execvpe`` does not return on
1245 success. Windows has no true exec, so the session runs as a child
1246 process and its exit code is propagated.
1247 """
1248 if sys.platform == "win32":
1249 return subprocess.call(argv, env=env)
1250 # Replacing the process is the point: the terminal becomes the agent
1251 # session. argv is the launcher built by this module, never a shell string.
1252 os.execvpe(argv[0], argv, env) # nosec B606
1253 raise AssertionError("unreachable: execvpe replaces the process on success")
1256def exec_codex(argv: list[str], env: dict[str, str]) -> int:
1257 """Hand the terminal over to Codex using the same process semantics."""
1258 return exec_claude(argv, env)
1261def build_opencode_owned_args(*, model: str) -> tuple[str, ...]:
1262 """Return the session-precedence controls that keep the OpenCode plan authoritative.
1264 The generated config already names the model, but OpenCode's CLI flags
1265 outrank every config file, so the selector is repeated on argv as defense
1266 in depth against ``OPENCODE_CONFIG_CONTENT`` or a stray global default.
1267 """
1268 return ("--model", opencode_model_selector(model))
1271#: OpenCode subcommands that are utilities rather than sessions (``mcp list``,
1272#: ``models``, ``providers``, ...). Each parses a strict option set of its
1273#: own, so the session flags (``--model``, ``--continue``/``--session``) are
1274#: withheld for them: passing ``--model`` to ``mcp list`` makes OpenCode print
1275#: usage and exit instead of running the command. The generated config still
1276#: carries the model, so nothing about the plan is lost. The root TUI (a
1277#: flag, a project path, or nothing) and ``run`` remain sessions.
1278_OPENCODE_UTILITY_SUBCOMMANDS: frozenset[str] = frozenset(
1279 {
1280 "acp",
1281 "agent",
1282 "attach",
1283 "auth",
1284 "completion",
1285 "db",
1286 "debug",
1287 "export",
1288 "github",
1289 "import",
1290 "mcp",
1291 "models",
1292 "plug",
1293 "plugin",
1294 "providers",
1295 "serve",
1296 "session",
1297 "stats",
1298 "uninstall",
1299 "web",
1300 }
1301)
1304def opencode_utility_invocation(extra_args: tuple[str, ...]) -> bool:
1305 """Return True when the passthrough targets a non-session OpenCode subcommand."""
1306 return bool(extra_args) and extra_args[0] in _OPENCODE_UTILITY_SUBCOMMANDS
1309def build_opencode_launch_argv(
1310 opencode_binary: str,
1311 *,
1312 root_args: tuple[str, ...] = (),
1313 resume_args: tuple[str, ...] = (),
1314 extra_args: tuple[str, ...] = (),
1315) -> list[str]:
1316 """Return OpenCode argv in owned-flags → resume-selector → passthrough order.
1318 Utility subcommands (see :data:`_OPENCODE_UTILITY_SUBCOMMANDS`) receive the
1319 passthrough verbatim: they reject the session flags, and they read the
1320 model and MCP set from the generated config anyway.
1321 """
1322 if opencode_utility_invocation(extra_args):
1323 return [opencode_binary, *extra_args]
1324 return [opencode_binary, *root_args, *resume_args, *extra_args]
1327def exec_opencode(argv: list[str], env: dict[str, str]) -> int:
1328 """Hand the terminal over to OpenCode using the same process semantics."""
1329 return exec_claude(argv, env)