feat(harness): add GitHub Copilot CLI harness bundle#295
Conversation
Implements harnesses/copilot/ with config.yaml, provision.py, capture_auth.py, Dockerfile, cloudbuild.yaml, and home/ directory files. The harness uses the container-script provisioner pattern with PAT-based auth via environment variable, MCP server translation (stdio→local, sse/streamable-http→http), and instruction projection to .github/copilot-instructions.md with managed block markers.
There was a problem hiding this comment.
Code Review
This pull request introduces a new harness bundle for the GitHub Copilot CLI, including Docker configuration, installation documentation, and scripts for provisioning and credential capture. The review feedback identified several areas where defensive programming should be improved, specifically by adding type validation for JSON data loaded from configuration files and ensuring safer string slicing when handling managed block markers in the instructions file.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if MANAGED_BEGIN in existing and MANAGED_END in existing: | ||
| before = existing[: existing.index(MANAGED_BEGIN)] | ||
| after = existing[existing.index(MANAGED_END) + len(MANAGED_END) :] | ||
| content = before + managed_block + after | ||
| elif existing.strip(): | ||
| content = managed_block + "\n\n" + existing | ||
| else: | ||
| content = managed_block + "\n" |
There was a problem hiding this comment.
If MANAGED_END appears before MANAGED_BEGIN in the file (e.g., due to manual user edits or corruption), using .index() directly without checking their relative order will result in incorrect slicing and scrambled content. We should check that begin_idx < end_idx before slicing.
| if MANAGED_BEGIN in existing and MANAGED_END in existing: | |
| before = existing[: existing.index(MANAGED_BEGIN)] | |
| after = existing[existing.index(MANAGED_END) + len(MANAGED_END) :] | |
| content = before + managed_block + after | |
| elif existing.strip(): | |
| content = managed_block + "\n\n" + existing | |
| else: | |
| content = managed_block + "\n" | |
| begin_idx = existing.find(MANAGED_BEGIN) | |
| end_idx = existing.find(MANAGED_END) | |
| if begin_idx != -1 and end_idx != -1 and begin_idx < end_idx: | |
| before = existing[:begin_idx] | |
| after = existing[end_idx + len(MANAGED_END):] | |
| content = before + managed_block + after | |
| elif existing.strip(): | |
| content = managed_block + "\\n\\n" + existing | |
| else: | |
| content = managed_block + "\\n" |
| settings: dict[str, Any] = {} | ||
| if os.path.isfile(settings_path): | ||
| try: | ||
| settings = _load_json(settings_path) or {} | ||
| except (OSError, json.JSONDecodeError): | ||
| settings = {} |
There was a problem hiding this comment.
If _load_json returns a non-dictionary (e.g., a JSON list, string, or boolean), settings will not be a dictionary. This will cause a TypeError later when iterating over defaults.items() or trying to assign keys to settings. We should defensively verify that the loaded JSON is a dictionary.
settings: dict[str, Any] = {}
if os.path.isfile(settings_path):
try:
loaded = _load_json(settings_path)
if isinstance(loaded, dict):
settings = loaded
except (OSError, json.JSONDecodeError):
pass| candidates: dict[str, Any] = {} | ||
| if os.path.isfile(auth_candidates_path): | ||
| try: | ||
| candidates = _load_json(auth_candidates_path) or {} | ||
| except (OSError, json.JSONDecodeError) as exc: | ||
| print( | ||
| f"copilot provision: invalid auth-candidates.json: {exc}", | ||
| file=sys.stderr, | ||
| ) | ||
| return EXIT_ERROR |
There was a problem hiding this comment.
If _load_json returns a non-dictionary (e.g., a JSON list or string), candidates will not be a dictionary. This will cause an AttributeError when calling candidates.get(...) later. We should defensively verify that the loaded JSON is a dictionary.
| candidates: dict[str, Any] = {} | |
| if os.path.isfile(auth_candidates_path): | |
| try: | |
| candidates = _load_json(auth_candidates_path) or {} | |
| except (OSError, json.JSONDecodeError) as exc: | |
| print( | |
| f"copilot provision: invalid auth-candidates.json: {exc}", | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR | |
| candidates: dict[str, Any] = {} | |
| if os.path.isfile(auth_candidates_path): | |
| try: | |
| loaded = _load_json(auth_candidates_path) | |
| if not isinstance(loaded, dict): | |
| raise ValueError("JSON is not an object") | |
| candidates = loaded | |
| except (OSError, json.JSONDecodeError, ValueError) as exc: | |
| print( | |
| f"copilot provision: invalid auth-candidates.json: {exc}", | |
| file=sys.stderr, | |
| ) | |
| return EXIT_ERROR |
| with open(config_path, "r", encoding="utf-8") as f: | ||
| try: | ||
| data = json.load(f) | ||
| except (json.JSONDecodeError, OSError): | ||
| return [] | ||
| creds = data.get("credentials") | ||
| if not isinstance(creds, list): | ||
| return [] | ||
| return creds |
There was a problem hiding this comment.
If json.load returns a non-dictionary (e.g., a JSON list or string), data will not be a dictionary. This will cause an AttributeError when calling data.get("credentials"). We should defensively verify that data is a dictionary.
| with open(config_path, "r", encoding="utf-8") as f: | |
| try: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return [] | |
| creds = data.get("credentials") | |
| if not isinstance(creds, list): | |
| return [] | |
| return creds | |
| with open(config_path, "r", encoding="utf-8") as f: | |
| try: | |
| data = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return [] | |
| if not isinstance(data, dict): | |
| return [] | |
| creds = data.get("credentials") | |
| if not isinstance(creds, list): | |
| return [] | |
| return creds |
Summary
harnesses/copilot/— a complete harness bundle for the GitHub Copilot CLI (copilotfromgithub/copilot-cli)COPILOT_GITHUB_TOKENenvironment variable (no credential file needed — Copilot reads auth from env vars directly)stdio→local,sse/streamable-http→http) in~/.copilot/mcp-config.json.github/copilot-instructions.mdwithSCION_MANAGED_BEGIN/ENDmarkers to protect injected contentFiles
config.yamlprovision.pycapture_auth.pyDockerfilecopilot-linuxmuslbinary onscion-basecloudbuild.yamlhome/.bashrccaliashome/.copilot/settings.jsonREADME.mdTest plan
python3 -c "import py_compile; py_compile.compile('provision.py')"passesdocker build --build-arg BASE_IMAGE=scion-base:latest .copilot --versioninside containerscion harness-config install harnesses/copilotscion start --harness copilot --env COPILOT_GITHUB_TOKEN=github_pat_...