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

1"""Autopilot: launch a configured Claude Code, Codex, or OpenCode session against GCO. 

2 

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``. 

14 

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. 

17 

18Scanner contract (``.github/scripts/lib_dependency_scan.sh``): 

19 

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""" 

28 

29from __future__ import annotations 

30 

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 

40 

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) 

47 

48from . import __version__ 

49 

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" 

54 

55#: npm package that ships the ``claude`` binary. 

56CLAUDE_CODE_PACKAGE = "@anthropic-ai/claude-code" 

57 

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" 

61 

62#: npm package that ships the ``codex`` binary. 

63CODEX_PACKAGE = "@openai/codex" 

64 

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" 

68 

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 

72 

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" 

76 

77#: npm package that ships the ``opencode`` binary. 

78OPENCODE_PACKAGE = "opencode-ai" 

79 

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" 

83 

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 

88 

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" 

93 

94 

95class AutopilotEngine(StrEnum): 

96 """Interactive agent runtimes supported by ``gco autopilot``.""" 

97 

98 CLAUDE_CODE = "claude-code" 

99 CODEX = "codex" 

100 OPENCODE = "opencode" 

101 

102 

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 

119 

120 

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" 

132 

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" 

136 

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" 

145 

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) 

160 

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} 

184 

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)\.") 

187 

188#: Model override environment variable (the ``--model`` flag wins over it). 

189_MODEL_ENV = "GCO_AUTOPILOT_MODEL" 

190 

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" 

195 

196#: Placeholder in companion ``args`` replaced with the launch directory. 

197_WORKSPACE_PLACEHOLDER = "{workspace}" 

198 

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" 

202 

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" 

206 

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" 

210 

211 

212@dataclass(frozen=True) 

213class CompanionServer: 

214 """One recommended companion MCP server from ``gco_mcp/README.md``. 

215 

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 """ 

220 

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) 

227 

228 

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) 

329 

330 

331def _source_checkout_root(candidate: Path | None = None) -> Path | None: 

332 """Return the GCO checkout root when autopilot runs from source. 

333 

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 

344 

345 

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) 

368 

369 

370def known_gco_mcp_flags() -> tuple[str, ...]: 

371 """Return every feature flag the GCO MCP server understands.""" 

372 return _KNOWN_GCO_MCP_FLAGS 

373 

374 

375def resolve_mcp_flags(enable: tuple[str, ...]) -> dict[str, str]: 

376 """Translate ``--enable`` values into env vars for the gco MCP server. 

377 

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} 

387 

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 

400 

401 

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. 

404 

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. 

409 

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 

436 

437 

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. 

444 

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} 

465 

466 

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 

472 

473 

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 

480 

481 

482def codex_home() -> Path: 

483 """Return GCO's isolated, persistent Codex home directory.""" 

484 return config_path().parent / _CODEX_HOME_DIRNAME 

485 

486 

487def codex_config_path() -> Path: 

488 """Return the generated Codex config path under the isolated home.""" 

489 return codex_home() / _CODEX_CONFIG_FILENAME 

490 

491 

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) 

495 

496 

497def _toml_array(values: list[str]) -> str: 

498 return "[" + ", ".join(_toml_string(value) for value in values) + "]" 

499 

500 

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. 

509 

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" 

562 

563 

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 

570 

571 

572def opencode_home() -> Path: 

573 """Return GCO's persistent OpenCode directory under the autopilot config dir.""" 

574 return config_path().parent / _OPENCODE_HOME_DIRNAME 

575 

576 

577def opencode_config_path() -> Path: 

578 """Return the generated ``opencode.json`` path GCO hands to ``OPENCODE_CONFIG``.""" 

579 return opencode_home() / _OPENCODE_CONFIG_FILENAME 

580 

581 

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}" 

585 

586 

587def opencode_aws_profile() -> str | None: 

588 """Return the AWS named profile the generated OpenCode config pins, if any. 

589 

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 

601 

602 

603def opencode_model_declaration(model: str) -> dict[str, object]: 

604 """Return the ``provider.amazon-bedrock.models`` entry for one model id. 

605 

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 

624 

625 

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. 

635 

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. 

644 

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 

698 

699 

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 

706 

707 

708def resolve_opencode_skills_paths(skills_dirs: tuple[str, ...]) -> tuple[Path, ...]: 

709 """Validate ``--skills`` sources and return the absolute paths OpenCode scans. 

710 

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) 

724 

725 

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. 

731 

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 

750 

751 

752def resolve_model(explicit: str | None) -> tuple[str, list[str]]: 

753 """Resolve the Claude Bedrock model with strict override validation. 

754 

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 

773 

774 

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 

790 

791 

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() 

801 

802 

803def resolve_opencode_model(explicit: str | None) -> tuple[str, list[str]]: 

804 """Resolve OpenCode model: flag > OpenCode env > generic env > cdk.json. 

805 

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 

826 

827 

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 

842 

843 

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. 

850 

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 

865 

866 

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 

873 

874 

875def build_opencode_env(region: str) -> dict[str, str]: 

876 """Return the OpenCode environment while preserving AWS credentials. 

877 

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 

892 

893 

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 

897 

898 

899def find_claude_binary() -> str | None: 

900 """Return the resolved ``claude`` executable path, or ``None``.""" 

901 return shutil.which("claude") 

902 

903 

904def find_codex_binary() -> str | None: 

905 """Return the resolved ``codex`` executable path, or ``None``.""" 

906 return shutil.which("codex") 

907 

908 

909def find_opencode_binary() -> str | None: 

910 """Return the resolved ``opencode`` executable path, or ``None``.""" 

911 return shutil.which("opencode") 

912 

913 

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(":")) 

919 

920 

921def resolve_plugin_paths(cli_plugins: tuple[str, ...]) -> list[Path]: 

922 """Resolve the plugin dirs/zips this session loads, validating each. 

923 

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()) 

932 

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 

944 

945 

946def validate_imports( 

947 skills_dirs: tuple[str, ...], 

948 agents_dirs: tuple[str, ...], 

949) -> None: 

950 """Validate ``--skills`` / ``--agents`` sources without staging anything. 

951 

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 ) 

972 

973 

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. 

979 

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``. 

987 

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) 

994 

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 ) 

1010 

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 

1017 

1018 

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 

1034 

1035 

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) 

1042 

1043 

1044#: Where Claude Code keeps per-project conversation transcripts. 

1045_CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" 

1046 

1047 

1048def _claude_project_dir_name(workspace: Path) -> str: 

1049 """Return Claude Code's directory name for a workspace path. 

1050 

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)) 

1056 

1057 

1058def has_resumable_session(workspace: Path) -> bool: 

1059 """Return whether Claude Code has a previous session for ``workspace``. 

1060 

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 

1073 

1074 

1075def claude_install_command() -> list[str]: 

1076 """Return the pinned, reproducible Claude Code install command. 

1077 

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 ] 

1091 

1092 

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()) 

1098 

1099 

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}"] 

1103 

1104 

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()) 

1110 

1111 

1112def opencode_install_command() -> list[str]: 

1113 """Return the pinned, reproducible OpenCode install command. 

1114 

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 ] 

1128 

1129 

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()) 

1135 

1136 

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. 

1145 

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 ] 

1165 

1166 

1167def codex_project_root(workspace: Path) -> Path: 

1168 """Return Codex's normalized project root for a run-scoped trust policy. 

1169 

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 

1190 

1191 

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. 

1200 

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 ) 

1221 

1222 args = ["--model", model] 

1223 for assignment in assignments: 

1224 args.extend(("-c", assignment)) 

1225 return tuple(args) 

1226 

1227 

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] 

1237 

1238 

1239def exec_claude(argv: list[str], env: dict[str, str]) -> int: 

1240 """Hand the terminal over to Claude Code. 

1241 

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") 

1254 

1255 

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) 

1259 

1260 

1261def build_opencode_owned_args(*, model: str) -> tuple[str, ...]: 

1262 """Return the session-precedence controls that keep the OpenCode plan authoritative. 

1263 

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)) 

1269 

1270 

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) 

1302 

1303 

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 

1307 

1308 

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. 

1317 

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] 

1325 

1326 

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)