Coverage for cli / autopilot.py: 100.00%

317 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

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

2 

3``gco autopilot`` defaults to Claude Code for backward compatibility and can 

4select Codex with ``--engine codex`` or ``GCO_AUTOPILOT_ENGINE=codex``. Both 

5engines use the caller's AWS credentials, GCO's canonical Bedrock defaults, 

6the GCO MCP server, and the recommended companion MCP servers. Claude Code 

7keeps its generated JSON config plus ``--strict-mcp-config`` behavior. Codex 

8uses a generated TOML config and skills inside GCO's isolated 

9``~/.gco/autopilot/codex`` home, leaving personal ``~/.codex`` state alone. 

10 

11Neither CLI is baked into the development container. Autopilot detects the 

12selected engine's binary and offers to install its exact npm pin lazily. 

13 

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

15 

16* ``extract_claude_code_pin`` and ``extract_codex_pin`` read 

17 :data:`CLAUDE_CODE_VERSION` and :data:`CODEX_VERSION` from this file with 

18 regexes — keep both as single-line, double-quoted assignments. 

19* ``extract_companion_mcp_packages`` pairs the ``registry=`` / ``package=`` 

20 keywords inside each ``CompanionServer(`` block — keep those two fields 

21 on their own lines when editing the registry below. 

22""" 

23 

24from __future__ import annotations 

25 

26import json 

27import os 

28import re 

29import shutil 

30import subprocess 

31import sys 

32from dataclasses import dataclass, field 

33from enum import StrEnum 

34from pathlib import Path 

35 

36from gco.bedrock import ( 

37 get_default_claude_code_model_id, 

38 get_default_codex_model_id, 

39 get_default_codex_reasoning_effort, 

40) 

41 

42from . import __version__ 

43 

44#: Exact Claude Code release installed by ``gco autopilot`` when the 

45#: ``claude`` binary is absent. Pinned (never ``latest``) so installs are 

46#: reproducible; the monthly deps-scan reports drift against npm. 

47CLAUDE_CODE_VERSION = "2.1.270" 

48 

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

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

51 

52#: Exact Codex CLI release installed by the Codex engine. 

53#: Keep this literal assignment scanner-friendly like CLAUDE_CODE_VERSION. 

54CODEX_VERSION = "0.154.0" 

55 

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

57CODEX_PACKAGE = "@openai/codex" 

58 

59#: Built-in Codex provider that signs Bedrock Runtime requests and supports 

60#: geographic/global cross-Region inference profile IDs. 

61CODEX_BEDROCK_PROVIDER = "amazon-bedrock-runtime" 

62 

63#: Parallel npx/uvx startup can include a first-use package download. Codex's 

64#: generated config gives every curated MCP server one bounded minute. 

65CODEX_MCP_STARTUP_TIMEOUT_SECONDS = 60.0 

66 

67#: Engine override environment variable; Claude Code remains the compatibility default. 

68_ENGINE_ENV = "GCO_AUTOPILOT_ENGINE" 

69_CODEX_MODEL_ENV = "GCO_AUTOPILOT_CODEX_MODEL" 

70 

71 

72class AutopilotEngine(StrEnum): 

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

74 

75 CLAUDE_CODE = "claude-code" 

76 CODEX = "codex" 

77 

78 

79def resolve_engine(explicit: str | AutopilotEngine | None) -> AutopilotEngine: 

80 """Resolve engine flag > environment > backward-compatible Claude default.""" 

81 if explicit is not None: 

82 raw: str | AutopilotEngine = explicit 

83 elif _ENGINE_ENV in os.environ: 

84 raw = os.environ[_ENGINE_ENV] 

85 else: 

86 return AutopilotEngine.CLAUDE_CODE 

87 if isinstance(raw, AutopilotEngine): 

88 return raw 

89 candidate = str(raw).strip().lower() 

90 try: 

91 return AutopilotEngine(candidate) 

92 except ValueError as exc: 

93 supported = ", ".join(engine.value for engine in AutopilotEngine) 

94 raise ValueError(f"Unknown autopilot engine {raw!r}. Choose one of: {supported}.") from exc 

95 

96 

97#: Where the generated session MCP config lands. Regenerated on every 

98#: launch, so hand edits do not survive — persistent customization belongs 

99#: in your own MCP config (see gco_mcp/README.md). 

100_CONFIG_DIR_ENV = "GCO_AUTOPILOT_CONFIG_DIR" 

101_DEFAULT_CONFIG_DIR = Path.home() / ".gco" / "autopilot" 

102_CONFIG_FILENAME = "mcp.json" 

103_CODEX_HOME_DIRNAME = "codex" 

104_CODEX_CONFIG_FILENAME = "config.toml" 

105_CODEX_SKILLS_DIRNAME = "skills" 

106 

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

108_MODEL_ENV = "GCO_AUTOPILOT_MODEL" 

109 

110#: Optional Bedrock model for Claude Code's background/fast tasks. Left 

111#: unset by default: the right haiku-class profile depends on what the 

112#: account has enabled, and Claude Code degrades gracefully without it. 

113_SMALL_FAST_MODEL_ENV = "GCO_AUTOPILOT_SMALL_FAST_MODEL" 

114 

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

116_WORKSPACE_PLACEHOLDER = "{workspace}" 

117 

118#: Colon-separated plugin dirs/zips always loaded into autopilot sessions 

119#: (merged with per-launch ``--plugin`` flags). 

120_PLUGIN_DIRS_ENV = "GCO_AUTOPILOT_PLUGIN_DIRS" 

121 

122#: Name of the synthetic session plugin that packages loose ``--skills`` / 

123#: ``--agents`` directories so Claude Code can load them. 

124_IMPORTS_PLUGIN_NAME = "gco-autopilot-imports" 

125 

126#: Read-only command allowlist for the shell companion. Deliberately tight — 

127#: no ``rm``, no ``git`` — matching the guidance in gco_mcp/README.md. 

128_SHELL_ALLOW_COMMANDS = "ls,cat,pwd,grep,wc,touch,find" 

129 

130 

131@dataclass(frozen=True) 

132class CompanionServer: 

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

134 

135 ``registry`` + ``package`` identify the distribution (``npm`` or 

136 ``pypi``) for the deps-scan liveness check; ``command`` + ``args`` + 

137 ``env`` are the stdio launch recipe written into the session config. 

138 """ 

139 

140 name: str 

141 registry: str 

142 package: str 

143 command: str 

144 args: tuple[str, ...] 

145 env: dict[str, str] = field(default_factory=dict) 

146 

147 

148#: The companion MCP servers wired into every autopilot session, one entry 

149#: per row of the "Recommended Companion MCP Servers" tables in 

150#: ``gco_mcp/README.md``. ``tests/test_cli_autopilot.py`` enforces that the 

151#: two stay in lockstep, and the monthly deps-scan verifies each package is 

152#: still published (and not deprecated/yanked) on its registry. 

153#: 

154#: The EKS server is configured read-only on purpose: an auto-generated 

155#: agent session should not silently hold cluster write access. Add 

156#: ``--allow-write`` / ``--allow-sensitive-data-access`` in your own MCP 

157#: config if you want the mutating tools. 

158COMPANION_MCP_SERVERS: tuple[CompanionServer, ...] = ( 

159 CompanionServer( 

160 name="aws-docs", 

161 registry="pypi", 

162 package="awslabs.aws-documentation-mcp-server", 

163 command="uvx", 

164 args=("awslabs.aws-documentation-mcp-server@latest",), 

165 env={"FASTMCP_LOG_LEVEL": "ERROR"}, 

166 ), 

167 CompanionServer( 

168 name="aws-pricing", 

169 registry="pypi", 

170 package="awslabs.aws-pricing-mcp-server", 

171 command="uvx", 

172 args=("awslabs.aws-pricing-mcp-server@latest",), 

173 env={"FASTMCP_LOG_LEVEL": "ERROR"}, 

174 ), 

175 CompanionServer( 

176 name="eks", 

177 registry="pypi", 

178 package="awslabs.eks-mcp-server", 

179 command="uvx", 

180 args=("awslabs.eks-mcp-server@latest",), 

181 env={"FASTMCP_LOG_LEVEL": "ERROR"}, 

182 ), 

183 CompanionServer( 

184 name="filesystem", 

185 registry="npm", 

186 package="@modelcontextprotocol/server-filesystem", 

187 command="npx", 

188 args=("-y", "@modelcontextprotocol/server-filesystem", _WORKSPACE_PLACEHOLDER), 

189 ), 

190 CompanionServer( 

191 name="ddg-search", 

192 registry="pypi", 

193 package="duckduckgo-mcp-server", 

194 command="uvx", 

195 args=("duckduckgo-mcp-server",), 

196 ), 

197 CompanionServer( 

198 name="deepwiki", 

199 registry="npm", 

200 package="mcp-deepwiki", 

201 command="npx", 

202 args=("-y", "mcp-deepwiki@latest"), 

203 ), 

204 CompanionServer( 

205 name="playwright", 

206 registry="npm", 

207 package="@playwright/mcp", 

208 command="npx", 

209 args=("-y", "@playwright/mcp@latest"), 

210 ), 

211 CompanionServer( 

212 name="sequential-thinking", 

213 registry="npm", 

214 package="@modelcontextprotocol/server-sequential-thinking", 

215 command="npx", 

216 args=("-y", "@modelcontextprotocol/server-sequential-thinking"), 

217 ), 

218 CompanionServer( 

219 name="inner-monologue", 

220 registry="npm", 

221 package="inner-monologue-mcp", 

222 command="npx", 

223 args=("-y", "inner-monologue-mcp"), 

224 ), 

225 CompanionServer( 

226 name="memory", 

227 registry="npm", 

228 package="@modelcontextprotocol/server-memory", 

229 command="npx", 

230 args=("-y", "@modelcontextprotocol/server-memory"), 

231 ), 

232 CompanionServer( 

233 name="mcp-tasks", 

234 registry="npm", 

235 package="mcp-tasks", 

236 command="npx", 

237 args=("-y", "mcp-tasks"), 

238 ), 

239 CompanionServer( 

240 name="shell", 

241 registry="pypi", 

242 package="mcp-shell-server", 

243 command="uvx", 

244 args=("mcp-shell-server",), 

245 env={"ALLOW_COMMANDS": _SHELL_ALLOW_COMMANDS}, 

246 ), 

247) 

248 

249 

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

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

252 

253 Mirrors the marker discipline in :mod:`gco.bedrock`: only the checkout 

254 that owns *this* file counts (``candidate`` exists for tests) — the 

255 current working directory is deliberately ignored so an unrelated 

256 project cannot redirect which MCP server code the session runs. 

257 """ 

258 root = candidate if candidate is not None else Path(__file__).resolve().parent.parent 

259 markers = (root / "app.py", root / "pyproject.toml", root / "gco_mcp" / "run_mcp.py") 

260 if all(marker.is_file() for marker in markers): 

261 return root 

262 return None 

263 

264 

265#: Every feature flag the GCO MCP server understands: the umbrella flag 

266#: first (a perfectly reasonable thing to pass to ``--enable``), then the 

267#: per-tool flags. This mirrors ``gco_mcp/feature_flags.py`` rather than 

268#: importing it — the CLI must not depend on the MCP package at runtime 

269#: (mypy also maps the PEP 420 namespace file under two module names when 

270#: both trees are checked together). ``tests/test_cli_autopilot.py`` holds 

271#: the two registries in lockstep, so drift fails the PR that introduces it. 

272_KNOWN_GCO_MCP_FLAGS: tuple[str, ...] = ( 

273 "GCO_ENABLE_ALL_TOOLS", 

274 "GCO_ENABLE_CAPACITY_PURCHASE", 

275 "GCO_ENABLE_MODEL_UPLOAD", 

276 "GCO_ENABLE_IMAGE_PUBLISH", 

277 "GCO_ENABLE_INFRASTRUCTURE_DEPLOY", 

278 "GCO_ENABLE_INFRASTRUCTURE_DESTROY", 

279 "GCO_ENABLE_DESTRUCTIVE_OPERATIONS", 

280 "GCO_ENABLE_MISSION", 

281 "GCO_ENABLE_LOCAL_METRICS", 

282 "GCO_ENABLE_LOCAL_STORAGE_SYNC", 

283 "GCO_ENABLE_SEMANTIC_PROGRESS", 

284 "GCO_ENABLE_CONFIG_MANAGEMENT", 

285 "GCO_ENABLE_SWARM", 

286) 

287 

288 

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

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

291 return _KNOWN_GCO_MCP_FLAGS 

292 

293 

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

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

296 

297 Accepts either the full env-var form (``GCO_ENABLE_MISSION``) or the 

298 bare suffix (``mission``, ``all-tools``, ``ALL_TOOLS``) and normalizes 

299 to the canonical name. Unknown flags raise ``ValueError`` listing the 

300 valid set — a typo should fail loudly at launch, not silently launch a 

301 session missing the tools the caller asked for. 

302 """ 

303 known = known_gco_mcp_flags() 

304 by_name = {flag: flag for flag in known} 

305 by_suffix = {flag.removeprefix("GCO_ENABLE_"): flag for flag in known} 

306 

307 resolved: dict[str, str] = {} 

308 for raw in enable: 

309 candidate = raw.strip().upper().replace("-", "_") 

310 flag = by_name.get(candidate) or by_suffix.get(candidate) 

311 if flag is None: 

312 suffixes = ", ".join(sorted(s.lower().replace("_", "-") for s in by_suffix)) 

313 raise ValueError( 

314 f"Unknown GCO MCP feature flag {raw!r}. Valid flags: {suffixes} " 

315 "(or their full GCO_ENABLE_* names)." 

316 ) 

317 resolved[flag] = "true" 

318 return resolved 

319 

320 

321def _gco_server_entry(mcp_env: dict[str, str] | None = None) -> dict[str, object]: 

322 """Build the ``gco`` MCP server entry for the session config. 

323 

324 From a source checkout the server runs straight off the working tree 

325 (``python3 gco_mcp/run_mcp.py``) so local MCP changes are live. From an 

326 installed ``gco-cli`` it runs the release tag matching ``__version__`` 

327 via ``uvx`` — the same no-clone form gco_mcp/README.md documents. 

328 

329 ``mcp_env`` carries feature flags (``GCO_ENABLE_*``) and any other 

330 server environment (for example ``GCO_MCP_TOOL_SEARCH``) into the 

331 server process. 

332 """ 

333 checkout = _source_checkout_root() 

334 entry: dict[str, object] 

335 if checkout is not None: 

336 entry = { 

337 "command": sys.executable or "python3", 

338 "args": [str(checkout / "gco_mcp" / "run_mcp.py")], 

339 } 

340 else: 

341 entry = { 

342 "command": "uvx", 

343 "args": [ 

344 "--python", 

345 "3.14", 

346 "--from", 

347 "git+https://github.com/aws-solutions-library-samples/global-capacity-orchestrator-on-aws.git" 

348 f"@v{__version__}", 

349 "gco-mcp", 

350 ], 

351 } 

352 if mcp_env: 

353 entry["env"] = dict(sorted(mcp_env.items())) 

354 return entry 

355 

356 

357def build_mcp_config( 

358 workspace: Path, 

359 include_companions: bool = True, 

360 gco_mcp_env: dict[str, str] | None = None, 

361) -> dict[str, dict[str, dict[str, object]]]: 

362 """Return the session ``mcpServers`` config for Claude Code. 

363 

364 ``workspace`` replaces the ``{workspace}`` placeholder in companion 

365 args (the filesystem server's root), so file access is scoped to the 

366 directory autopilot was launched from. ``gco_mcp_env`` is applied to 

367 the gco server entry only — feature flags gate GCO tools, not the 

368 companions. 

369 """ 

370 servers: dict[str, dict[str, object]] = {"gco": _gco_server_entry(gco_mcp_env)} 

371 if include_companions: 

372 for companion in COMPANION_MCP_SERVERS: 

373 entry: dict[str, object] = { 

374 "command": companion.command, 

375 "args": [ 

376 str(workspace) if arg == _WORKSPACE_PLACEHOLDER else arg 

377 for arg in companion.args 

378 ], 

379 } 

380 if companion.env: 

381 entry["env"] = dict(companion.env) 

382 servers[companion.name] = entry 

383 return {"mcpServers": servers} 

384 

385 

386def config_path() -> Path: 

387 """Return the on-disk location of the generated session MCP config.""" 

388 override = os.environ.get(_CONFIG_DIR_ENV) 

389 directory = Path(override).expanduser() if override else _DEFAULT_CONFIG_DIR 

390 return directory / _CONFIG_FILENAME 

391 

392 

393def write_mcp_config(config: dict[str, dict[str, dict[str, object]]]) -> Path: 

394 """Write the session MCP config and return its path.""" 

395 path = config_path() 

396 path.parent.mkdir(parents=True, exist_ok=True) 

397 path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") 

398 return path 

399 

400 

401def codex_home() -> Path: 

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

403 return config_path().parent / _CODEX_HOME_DIRNAME 

404 

405 

406def codex_config_path() -> Path: 

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

408 return codex_home() / _CODEX_CONFIG_FILENAME 

409 

410 

411def _toml_string(value: str) -> str: 

412 """Encode one TOML basic string using JSON's compatible escaping.""" 

413 return json.dumps(value, ensure_ascii=False) 

414 

415 

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

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

418 

419 

420def build_codex_config_toml( 

421 mcp_config: dict[str, dict[str, dict[str, object]]], 

422 *, 

423 model: str, 

424 region: str, 

425 reasoning_effort: str | None, 

426) -> str: 

427 """Render a complete isolated Codex config with Bedrock and MCP servers. 

428 

429 ``model_reasoning_effort`` is emitted only for the canonical GCO model. 

430 Explicit CLI/environment model overrides must use the selected model's own 

431 defaults instead of inheriting a potentially incompatible effort value. 

432 """ 

433 lines = [ 

434 f"model = {_toml_string(model)}", 

435 f"model_provider = {_toml_string(CODEX_BEDROCK_PROVIDER)}", 

436 ] 

437 if reasoning_effort is not None: 

438 lines.append(f"model_reasoning_effort = {_toml_string(reasoning_effort)}") 

439 lines.extend( 

440 [ 

441 "check_for_update_on_startup = false", 

442 "", 

443 f"[model_providers.{CODEX_BEDROCK_PROVIDER}]", 

444 'wire_api = "responses"', 

445 "", 

446 f"[model_providers.{CODEX_BEDROCK_PROVIDER}.aws]", 

447 f"region = {_toml_string(region)}", 

448 ] 

449 ) 

450 servers = mcp_config["mcpServers"] 

451 for name in sorted(servers): 

452 entry = servers[name] 

453 command = entry.get("command") 

454 args = entry.get("args", []) 

455 if not isinstance(command, str) or not command: 

456 raise ValueError(f"Codex MCP server {name!r} has no command") 

457 if not isinstance(args, list) or not all(isinstance(item, str) for item in args): 

458 raise ValueError(f"Codex MCP server {name!r} args must be strings") 

459 table_name = _toml_string(name) 

460 lines.extend( 

461 [ 

462 "", 

463 f"[mcp_servers.{table_name}]", 

464 f"command = {_toml_string(command)}", 

465 f"args = {_toml_array(args)}", 

466 "enabled = true", 

467 f"startup_timeout_sec = {CODEX_MCP_STARTUP_TIMEOUT_SECONDS}", 

468 ] 

469 ) 

470 environment = entry.get("env") 

471 if environment: 

472 if not isinstance(environment, dict) or not all( 

473 isinstance(key, str) and isinstance(value, str) 

474 for key, value in environment.items() 

475 ): 

476 raise ValueError(f"Codex MCP server {name!r} env must contain strings") 

477 lines.extend(["", f"[mcp_servers.{table_name}.env]"]) 

478 for key, value in sorted(environment.items()): 

479 lines.append(f"{_toml_string(key)} = {_toml_string(value)}") 

480 return "\n".join(lines) + "\n" 

481 

482 

483def write_codex_config(content: str) -> Path: 

484 """Write the generated Codex configuration inside isolated CODEX_HOME.""" 

485 path = codex_config_path() 

486 path.parent.mkdir(parents=True, exist_ok=True) 

487 path.write_text(content, encoding="utf-8") 

488 return path 

489 

490 

491def _selected_model_override( 

492 explicit: str | None, 

493 env_names: tuple[str, ...], 

494) -> tuple[str | None, str | None]: 

495 """Return the highest-precedence model override and its user-facing source. 

496 

497 Presence, not truthiness, selects a source so a blank high-precedence value 

498 fails closed instead of silently falling through to another model. 

499 """ 

500 if explicit is not None: 

501 raw_value = explicit 

502 source = "--model" 

503 else: 

504 for env_name in env_names: 

505 if env_name in os.environ: 

506 raw_value = os.environ[env_name] 

507 source = env_name 

508 break 

509 else: 

510 return None, None 

511 value = raw_value.strip() 

512 if not value: 

513 raise ValueError(f"{source} must be a non-empty Bedrock model id") 

514 return value, source 

515 

516 

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

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

519 

520 Precedence: ``--model`` flag > ``GCO_AUTOPILOT_MODEL`` env > the 

521 ``cdk.json`` Claude Code default 

522 (``context.bedrock.claude_code_default_model_id``, deliberately separate 

523 from the advisory default Mission and the capacity advisor share). The 

524 result is advisory-validated only: Bedrock ids for Claude contain 

525 ``anthropic``/``claude``, but application inference-profile ARNs are 

526 opaque, so an unfamiliar id produces a warning rather than a refusal. 

527 """ 

528 warnings: list[str] = [] 

529 override, _source = _selected_model_override(explicit, (_MODEL_ENV,)) 

530 model = override if override is not None else get_default_claude_code_model_id() 

531 lowered = model.lower() 

532 if "anthropic" not in lowered and "claude" not in lowered: 

533 warnings.append( 

534 f"Model id {model!r} does not look like a Claude model on Bedrock. " 

535 "Claude Code is tuned for Claude models; continuing anyway." 

536 ) 

537 return model, warnings 

538 

539 

540def resolve_codex_model(explicit: str | None) -> tuple[str, list[str]]: 

541 """Resolve Codex model: flag > Codex env > generic env > cdk.json.""" 

542 warnings: list[str] = [] 

543 override, _source = _selected_model_override( 

544 explicit, 

545 (_CODEX_MODEL_ENV, _MODEL_ENV), 

546 ) 

547 model = override if override is not None else get_default_codex_model_id() 

548 lowered = model.lower() 

549 if "openai" not in lowered and "gpt" not in lowered: 

550 warnings.append( 

551 f"Model id {model!r} does not look like an OpenAI model on Bedrock; " 

552 "continuing with the explicit override." 

553 ) 

554 return model, warnings 

555 

556 

557def resolve_codex_reasoning_effort(explicit_model: str | None = None) -> str | None: 

558 """Return canonical Codex effort only when no model override was selected.""" 

559 override, _source = _selected_model_override( 

560 explicit_model, 

561 (_CODEX_MODEL_ENV, _MODEL_ENV), 

562 ) 

563 if override is not None: 

564 return None 

565 return get_default_codex_reasoning_effort() 

566 

567 

568def resolve_small_fast_model(explicit: str | None) -> str | None: 

569 """Resolve Claude's optional fast model, rejecting configured blank values.""" 

570 if explicit is not None: 

571 raw_value = explicit 

572 source = "--small-fast-model" 

573 elif _SMALL_FAST_MODEL_ENV in os.environ: 

574 raw_value = os.environ[_SMALL_FAST_MODEL_ENV] 

575 source = _SMALL_FAST_MODEL_ENV 

576 else: 

577 return None 

578 value = raw_value.strip() 

579 if not value: 

580 raise ValueError(f"{source} must be a non-empty Bedrock model id") 

581 return value 

582 

583 

584def build_claude_env( 

585 model: str, 

586 region: str, 

587 small_fast_model: str | None = None, 

588) -> dict[str, str]: 

589 """Return the environment for the Claude Code process. 

590 

591 Starts from the caller's environment so AWS credentials, profiles, and 

592 proxies pass through untouched. An ``AWS_REGION`` already set by the 

593 caller wins over the GCO-configured region — least surprise for anyone 

594 juggling AWS environments — and ``ANTHROPIC_SMALL_FAST_MODEL`` is only 

595 set when a background model was explicitly chosen. 

596 """ 

597 env = dict(os.environ) 

598 env["CLAUDE_CODE_USE_BEDROCK"] = "1" 

599 env["DISABLE_AUTOUPDATER"] = "1" 

600 env["ANTHROPIC_MODEL"] = model 

601 env.setdefault("AWS_REGION", region) 

602 if small_fast_model: 

603 env["ANTHROPIC_SMALL_FAST_MODEL"] = small_fast_model 

604 return env 

605 

606 

607def build_codex_env(region: str) -> dict[str, str]: 

608 """Return an isolated Codex environment while preserving AWS credentials.""" 

609 env = dict(os.environ) 

610 env.setdefault("AWS_REGION", region) 

611 env["CODEX_HOME"] = str(codex_home()) 

612 return env 

613 

614 

615def effective_aws_region(default_region: str) -> str: 

616 """Resolve the AWS SDK region Codex and its generated config should share.""" 

617 return os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or default_region 

618 

619 

620def find_claude_binary() -> str | None: 

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

622 return shutil.which("claude") 

623 

624 

625def find_codex_binary() -> str | None: 

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

627 return shutil.which("codex") 

628 

629 

630def plugin_paths_requested(cli_plugins: tuple[str, ...]) -> bool: 

631 """Return whether CLI or environment requested a Claude Code plugin.""" 

632 if cli_plugins: 

633 return True 

634 return any(part.strip() for part in os.environ.get(_PLUGIN_DIRS_ENV, "").split(":")) 

635 

636 

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

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

639 

640 Merges ``--plugin`` flags with the ``GCO_AUTOPILOT_PLUGIN_DIRS`` 

641 environment variable (colon-separated, for the "always bring my team's 

642 plugin" case). A missing path raises ``ValueError`` — silently launching 

643 without the skills someone asked for is the failure mode this guards. 

644 """ 

645 raw: list[str] = list(cli_plugins) 

646 env_value = os.environ.get(_PLUGIN_DIRS_ENV, "") 

647 raw.extend(part for part in env_value.split(":") if part.strip()) 

648 

649 resolved: list[Path] = [] 

650 seen: set[Path] = set() 

651 for item in raw: 

652 path = Path(item).expanduser() 

653 if not path.exists(): 

654 raise ValueError(f"Plugin path does not exist: {path}") 

655 path = path.resolve() 

656 if path not in seen: 

657 seen.add(path) 

658 resolved.append(path) 

659 return resolved 

660 

661 

662def validate_imports( 

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

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

665) -> None: 

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

667 

668 Shared by the dry-run/plan path (which must not write) and by 

669 :func:`stage_imports`. ``skills_dirs`` entries must contain at least one 

670 ``*/SKILL.md``; ``agents_dirs`` entries at least one ``*.md``. Anything 

671 else raises ``ValueError`` — an empty import is a typo'd path, not a 

672 preference. 

673 """ 

674 for kind, sources, marker in ( 

675 ("skills", skills_dirs, "*/SKILL.md"), 

676 ("agents", agents_dirs, "*.md"), 

677 ): 

678 for source_raw in sources: 

679 source = Path(source_raw).expanduser() 

680 if not source.is_dir(): 

681 raise ValueError(f"--{kind} path is not a directory: {source}") 

682 if not any(source.glob(marker)): 

683 raise ValueError( 

684 f"--{kind} directory {source} contains no {marker}" 

685 "check the path (skills are one subdirectory per skill " 

686 "with a SKILL.md; agents are *.md files)." 

687 ) 

688 

689 

690def stage_imports( 

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

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

693) -> Path | None: 

694 """Package loose skills/agents directories as a session plugin. 

695 

696 Claude Code loads skills and agents from ``~/.claude`` and the 

697 workspace's ``.claude`` automatically; this exists for everything 

698 *else* — a team repo of skills, a scratch directory of agent files — 

699 without copying anything into the user's project or personal config. 

700 The staged plugin lives next to the generated MCP config, is rebuilt 

701 from scratch on every launch (hand edits do not survive), and is 

702 handed to claude with ``--plugin-dir``. 

703 

704 Sources are validated by :func:`validate_imports` first. Returns the 

705 plugin directory, or ``None`` when nothing was imported. 

706 """ 

707 if not skills_dirs and not agents_dirs: 

708 return None 

709 validate_imports(skills_dirs, agents_dirs) 

710 

711 plugin_root = config_path().parent / _IMPORTS_PLUGIN_NAME 

712 shutil.rmtree(plugin_root, ignore_errors=True) 

713 manifest_dir = plugin_root / ".claude-plugin" 

714 manifest_dir.mkdir(parents=True) 

715 manifest = { 

716 "name": _IMPORTS_PLUGIN_NAME, 

717 "version": __version__, 

718 "description": ( 

719 "Session-scoped skills/agents imported by `gco autopilot " 

720 "--skills/--agents`. Regenerated on every launch." 

721 ), 

722 } 

723 (manifest_dir / "plugin.json").write_text( 

724 json.dumps(manifest, indent=2) + "\n", encoding="utf-8" 

725 ) 

726 

727 for kind, sources in (("skills", skills_dirs), ("agents", agents_dirs)): 

728 destination = plugin_root / kind 

729 for source_raw in sources: 

730 source = Path(source_raw).expanduser() 

731 shutil.copytree(source, destination, dirs_exist_ok=True) 

732 return plugin_root 

733 

734 

735def stage_codex_skills(skills_dirs: tuple[str, ...]) -> Path | None: 

736 """Rebuild GCO's isolated Codex skills directory from validated sources.""" 

737 destination = codex_home() / _CODEX_SKILLS_DIRNAME 

738 shutil.rmtree(destination, ignore_errors=True) 

739 if not skills_dirs: 

740 return None 

741 validate_imports(skills_dirs, ()) 

742 destination.mkdir(parents=True) 

743 for source_raw in skills_dirs: 

744 shutil.copytree( 

745 Path(source_raw).expanduser(), 

746 destination, 

747 dirs_exist_ok=True, 

748 ) 

749 return destination 

750 

751 

752def build_plugin_args(plugin_paths: list[Path]) -> tuple[str, ...]: 

753 """Render plugin paths as claude's repeatable ``--plugin-dir`` flags.""" 

754 args: list[str] = [] 

755 for path in plugin_paths: 

756 args.extend(("--plugin-dir", str(path))) 

757 return tuple(args) 

758 

759 

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

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

762 

763 

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

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

766 

767 Claude Code names each entry under ``~/.claude/projects`` by replacing 

768 every non-alphanumeric character of the absolute workspace path with 

769 ``-`` (so ``/Users/dev/my_repo`` becomes ``-Users-dev-my-repo``). 

770 """ 

771 return re.sub(r"[^A-Za-z0-9]", "-", str(workspace)) 

772 

773 

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

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

776 

777 Peeks at Claude Code's own transcript store (one ``*.jsonl`` per 

778 conversation). The layout is Claude Code internal, so this check is 

779 deliberately fail-quiet: if the directory scheme ever changes, the 

780 resume prompt silently stops appearing while the explicit 

781 ``--continue`` / ``--resume`` flags — which claude interprets itself — 

782 keep working unchanged. 

783 """ 

784 try: 

785 project_dir = _CLAUDE_PROJECTS_DIR / _claude_project_dir_name(workspace) 

786 return any(project_dir.glob("*.jsonl")) 

787 except OSError: 

788 return False 

789 

790 

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

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

793 

794 ``--allow-scripts`` names exactly this one package: Claude Code's 

795 postinstall downloads the platform-native binary, and npm >= 12 blocks 

796 lifecycle scripts by default, which would otherwise leave a shim on 

797 PATH that fails with ``Exec format error`` on launch. Older npm (< 12) 

798 accepts and ignores the flag, so one command form works everywhere. 

799 """ 

800 return [ 

801 "npm", 

802 "install", 

803 "-g", 

804 f"--allow-scripts={CLAUDE_CODE_PACKAGE}", 

805 f"{CLAUDE_CODE_PACKAGE}@{CLAUDE_CODE_VERSION}", 

806 ] 

807 

808 

809def install_claude_code() -> int: 

810 """Install the pinned Claude Code release; return the npm exit code.""" 

811 if shutil.which("npm") is None: 

812 return 127 

813 return subprocess.call(claude_install_command()) # noqa: S603 

814 

815 

816def codex_install_command() -> list[str]: 

817 """Return the exact pinned Codex npm install command.""" 

818 return ["npm", "install", "-g", f"{CODEX_PACKAGE}@{CODEX_VERSION}"] 

819 

820 

821def install_codex() -> int: 

822 """Install the pinned Codex release; return the npm exit code.""" 

823 if shutil.which("npm") is None: 

824 return 127 

825 return subprocess.call(codex_install_command()) # noqa: S603 

826 

827 

828def build_launch_argv( 

829 claude_binary: str, 

830 mcp_config: Path, 

831 extra_args: tuple[str, ...] = (), 

832 resume_args: tuple[str, ...] = (), 

833 plugin_args: tuple[str, ...] = (), 

834) -> list[str]: 

835 """Return the Claude Code argv for a hermetic autopilot session. 

836 

837 ``--strict-mcp-config`` makes the generated config the *only* MCP 

838 config: personal ``~/.claude`` servers and project ``.mcp.json`` files 

839 are ignored, so every autopilot session starts from the same known-good 

840 server set. ``resume_args`` carries claude's native session-resumption 

841 flags (``--continue`` / ``--resume [id]``) when the caller asked to 

842 pick up an earlier conversation; the resumed session still runs under 

843 this launch's MCP config and Bedrock environment. ``plugin_args`` 

844 carries the ``--plugin-dir`` flags for session-scoped plugins and the 

845 staged skills/agents imports. 

846 """ 

847 return [ 

848 claude_binary, 

849 "--mcp-config", 

850 str(mcp_config), 

851 "--strict-mcp-config", 

852 *plugin_args, 

853 *resume_args, 

854 *extra_args, 

855 ] 

856 

857 

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

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

860 

861 Codex 0.150.1 loads a trusted project's ``.codex/config.toml`` above its 

862 user config, even when ``CODEX_HOME`` is isolated. Its Git project identity 

863 follows the common Git directory, so linked worktrees resolve to the main 

864 checkout. Outside Git, the launch directory itself is the project root. 

865 """ 

866 resolved = workspace.resolve() 

867 try: 

868 result = subprocess.run( 

869 ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], 

870 cwd=resolved, 

871 capture_output=True, 

872 text=True, 

873 check=False, 

874 timeout=5, 

875 ) 

876 except OSError, subprocess.TimeoutExpired: 

877 return resolved 

878 if result.returncode != 0 or not result.stdout.strip(): 

879 return resolved 

880 return Path(result.stdout.strip()).resolve().parent 

881 

882 

883def build_codex_owned_args( 

884 *, 

885 model: str, 

886 region: str, 

887 reasoning_effort: str | None, 

888 workspace: Path, 

889) -> tuple[str, ...]: 

890 """Return session-precedence controls that keep the Codex plan authoritative. 

891 

892 ``CODEX_HOME`` isolates personal user state, but Codex 0.150.1 otherwise 

893 layers trusted project configuration above the generated TOML. Marking the 

894 discovered project root untrusted for this launch disables that project 

895 layer without persisting trust state. Scalar Bedrock/update controls are 

896 repeated at session precedence as defense in depth. Organization-managed 

897 policy remains authoritative by Codex design. 

898 """ 

899 project_root = codex_project_root(workspace) 

900 assignments = [ 

901 f"model_provider={_toml_string(CODEX_BEDROCK_PROVIDER)}", 

902 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.wire_api={_toml_string('responses')}"), 

903 (f"model_providers.{CODEX_BEDROCK_PROVIDER}.aws.region={_toml_string(region)}"), 

904 "check_for_update_on_startup=false", 

905 (f'projects={{{_toml_string(str(project_root))}={{trust_level="untrusted"}}}}'), 

906 ] 

907 if reasoning_effort is not None: 

908 assignments.insert( 

909 1, 

910 f"model_reasoning_effort={_toml_string(reasoning_effort)}", 

911 ) 

912 

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

914 for assignment in assignments: 

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

916 return tuple(args) 

917 

918 

919def build_codex_launch_argv( 

920 codex_binary: str, 

921 *, 

922 root_args: tuple[str, ...] = (), 

923 resume_args: tuple[str, ...] = (), 

924 extra_args: tuple[str, ...] = (), 

925) -> list[str]: 

926 """Return Codex argv in native root → resume-selector → passthrough order.""" 

927 return [codex_binary, *root_args, *resume_args, *extra_args] 

928 

929 

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

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

932 

933 On POSIX the process image is replaced (``execvpe``) so the terminal 

934 *becomes* the session — no wrapper process lingers, signals and TTY 

935 behavior are exactly claude's own. ``execvpe`` does not return on 

936 success. Windows has no true exec, so the session runs as a child 

937 process and its exit code is propagated. 

938 """ 

939 if sys.platform == "win32": 

940 return subprocess.call(argv, env=env) # noqa: S603 

941 os.execvpe(argv[0], argv, env) # noqa: S606 

942 raise AssertionError("unreachable: execvpe replaces the process on success") 

943 

944 

945def exec_codex(argv: list[str], env: dict[str, str]) -> int: 

946 """Hand the terminal over to Codex using the same process semantics.""" 

947 return exec_claude(argv, env)