From 181802664ee54ffff3dec7b8d8f56647cde22c35 Mon Sep 17 00:00:00 2001 From: "Scion Agent (copilot-harness-dev)" Date: Thu, 25 Jun 2026 12:58:00 +0000 Subject: [PATCH] feat(harness): add GitHub Copilot CLI harness bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- harnesses/copilot/Dockerfile | 36 ++ harnesses/copilot/README.md | 68 +++ harnesses/copilot/capture_auth.py | 170 ++++++ harnesses/copilot/cloudbuild.yaml | 57 ++ harnesses/copilot/config.yaml | 102 ++++ harnesses/copilot/home/.bashrc | 4 + harnesses/copilot/home/.copilot/settings.json | 3 + harnesses/copilot/provision.py | 557 ++++++++++++++++++ 8 files changed, 997 insertions(+) create mode 100644 harnesses/copilot/Dockerfile create mode 100644 harnesses/copilot/README.md create mode 100644 harnesses/copilot/capture_auth.py create mode 100644 harnesses/copilot/cloudbuild.yaml create mode 100644 harnesses/copilot/config.yaml create mode 100644 harnesses/copilot/home/.bashrc create mode 100644 harnesses/copilot/home/.copilot/settings.json create mode 100644 harnesses/copilot/provision.py diff --git a/harnesses/copilot/Dockerfile b/harnesses/copilot/Dockerfile new file mode 100644 index 000000000..03ca7d194 --- /dev/null +++ b/harnesses/copilot/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +ARG COPILOT_CLI_VERSION=latest + +RUN mkdir -p /home/scion/.copilot /home/scion/.copilot/skills \ + /home/scion/.github && \ + chown -R scion:scion /home/scion/.copilot /home/scion/.github + +# Install copilot binary (Alpine/musl variant for scion-base). +# The binary is a self-contained Node.js SEA — no npm/node dependency needed. +RUN ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') && \ + if [ "$COPILOT_CLI_VERSION" = "latest" ]; then \ + URL="https://github.com/github/copilot-cli/releases/latest/download/copilot-linuxmusl-${ARCH}.tar.gz"; \ + else \ + URL="https://github.com/github/copilot-cli/releases/download/v${COPILOT_CLI_VERSION}/copilot-linuxmusl-${ARCH}.tar.gz"; \ + fi && \ + curl -fsSL "$URL" | tar xz -C /usr/local/bin && \ + chmod +x /usr/local/bin/copilot + +CMD ["copilot"] diff --git a/harnesses/copilot/README.md b/harnesses/copilot/README.md new file mode 100644 index 000000000..884f10e3a --- /dev/null +++ b/harnesses/copilot/README.md @@ -0,0 +1,68 @@ +# Copilot Harness Bundle + +Scion harness bundle for the [GitHub Copilot CLI](https://github.com/github/copilot-cli) +(`copilot` from `github/copilot-cli`). + +## Bundle Layout + +``` +harnesses/copilot/ + config.yaml # Harness configuration + provision.py # Container-side provisioner (pre-start hook) + capture_auth.py # Post-login credential capture + Dockerfile # Image build (FROM scion-base) + cloudbuild.yaml # Cloud Build configuration + README.md # This file + home/ + .bashrc # Shell initialization + .copilot/ + settings.json # Default settings (auto-update off) +``` + +## Installation + +```bash +scion harness-config install harnesses/copilot +``` + +## Authentication + +The Copilot CLI requires a GitHub account with an active Copilot subscription. + +### Fine-Grained PAT (Recommended) + +Create a [fine-grained personal access token](https://github.com/settings/personal-access-tokens) +with the **"Copilot Requests"** permission enabled. The token must be user-owned +(not organization-owned). + +```bash +scion start --harness copilot --env COPILOT_GITHUB_TOKEN=github_pat_... +``` + +Token precedence: `COPILOT_GITHUB_TOKEN` > `GH_TOKEN` > `GITHUB_TOKEN`. + +**Note:** Classic PATs (`ghp_...`) are not supported by the Copilot CLI. + +### Interactive Login (No-Auth Fallback) + +If no token is provided, the agent drops to a shell. Run `copilot login` to +authenticate via browser-based OAuth device flow, then capture credentials: + +```bash +python3 /home/scion/.scion/harness/capture_auth.py +``` + +## Known Limitations + +- **No turn/model-call limits** — Copilot CLI has no hook dialect for individual + turn or model call events. Only `max_duration` (via Scion's external timeout) + is supported. +- **No telemetry integration** — Copilot's OpenTelemetry configuration surface + is undocumented. +- **System prompt is approximate** — system prompt content is prepended to + `.github/copilot-instructions.md`; there is no native `--system-prompt` flag. +- **No project-scoped MCP** — project-scoped MCP server entries are demoted to + global scope. +- **Subscription required** — an active GitHub Copilot subscription is required. + The harness provisions successfully without one, but the CLI will fail with an + auth error after launch. diff --git a/harnesses/copilot/capture_auth.py b/harnesses/copilot/capture_auth.py new file mode 100644 index 000000000..a56bf8128 --- /dev/null +++ b/harnesses/copilot/capture_auth.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Copilot capture-auth script. + +Scans for credential files on disk and stores them as project-scoped secrets +via `sciontool secret set`. Designed to run after the user authenticates +interactively inside a no-auth agent container (e.g. after `copilot login`). + +Reads credential mappings from inputs/capture-auth-config.json (derived from +the harness config.yaml's auth.types.*.required_files declarations). This +avoids hardcoding paths or key names in the script. + +Exit codes: + 0 = at least one credential captured + 1 = error + 2 = no credentials found (not an error, but nothing was stored) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_NO_CREDS = 2 + +HARNESS_BUNDLE = os.path.join( + os.environ.get("HOME") or os.path.expanduser("~"), + ".scion", "harness", +) + + +def _expand(path: str) -> str: + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_config(bundle: str) -> list[dict[str, Any]]: + config_path = os.path.join(bundle, "inputs", "capture-auth-config.json") + if not os.path.isfile(config_path): + return [] + 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 + + +def _capture_one( + entry: dict[str, Any], force: bool +) -> tuple[bool, str | None]: + """Attempt to capture a single credential. Returns (success, error_msg).""" + key = entry.get("key", "") + source = _expand(entry.get("source", "")) + secret_type = entry.get("type", "file") + target = entry.get("target", "") + + if not key or not source: + return False, "invalid entry: missing key or source" + + if not os.path.isfile(source): + return False, None + + cmd = [ + "sciontool", "secret", "set", key, f"@{source}", + "--type", secret_type, + "--target", target, + ] + if force: + cmd.append("--force") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30, + ) + except FileNotFoundError: + return False, "sciontool not found in PATH" + except subprocess.TimeoutExpired: + return False, f"sciontool timed out for key {key}" + + if result.returncode != 0: + stderr = result.stderr.strip() + if "already exists" in stderr.lower(): + return True, None + return False, f"sciontool failed for {key}: {stderr}" + + return True, None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Capture auth credentials and store as project secrets" + ) + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing secrets", + ) + parser.add_argument( + "--bundle", + default=HARNESS_BUNDLE, + help="Path to harness bundle directory", + ) + args = parser.parse_args() + + entries = _load_config(args.bundle) + if not entries: + print( + "capture-auth: no credential mappings found in " + "inputs/capture-auth-config.json", + file=sys.stderr, + ) + return EXIT_NO_CREDS + + captured = 0 + errors = 0 + + for entry in entries: + key = entry.get("key", "") + source = entry.get("source", "") + expanded = _expand(source) if source else "" + + if not expanded or not os.path.isfile(expanded): + print(f"capture-auth: {key}: source not found ({source})") + continue + + ok, err = _capture_one(entry, args.force) + if err: + print(f"capture-auth: {key}: {err}", file=sys.stderr) + errors += 1 + elif ok: + print(f"capture-auth: {key}: captured from {source}") + captured += 1 + + if errors > 0 and captured == 0: + return EXIT_ERROR + + if captured == 0: + print("capture-auth: no credentials found to capture") + return EXIT_NO_CREDS + + print(f"capture-auth: {captured} credential(s) captured successfully") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/harnesses/copilot/cloudbuild.yaml b/harnesses/copilot/cloudbuild.yaml new file mode 100644 index 000000000..4bfc81b72 --- /dev/null +++ b/harnesses/copilot/cloudbuild.yaml @@ -0,0 +1,57 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Per-bundle Cloud Build configuration for the Copilot harness image. +# Builds scion-copilot on top of scion-base:<_TAG>. +steps: + - name: 'gcr.io/cloud-builders/docker' + id: 'setup-buildx' + args: ['buildx', 'create', '--name', 'mybuilder', '--use'] + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + + - name: 'gcr.io/cloud-builders/docker' + id: 'bootstrap-buildx' + args: ['buildx', 'inspect', '--bootstrap'] + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + + - name: 'gcr.io/cloud-builders/docker' + id: 'build-scion-copilot' + args: + - 'buildx' + - 'build' + - '--platform' + - 'linux/amd64,linux/arm64' + - '--build-arg' + - 'BASE_IMAGE=$_REGISTRY/scion-base:$_TAG' + - '-t' + - '$_REGISTRY/scion-copilot:$_SHORT_SHA' + - '-t' + - '$_REGISTRY/scion-copilot:$_TAG' + - '-f' + - 'Dockerfile' + - '--pull' + - '--push' + - '.' + env: + - 'DOCKER_CLI_EXPERIMENTAL=enabled' + +substitutions: + _REGISTRY: 'us-central1-docker.pkg.dev/${PROJECT_ID}/public-docker' + _TAG: 'latest' +options: + dynamicSubstitutions: true + machineType: 'E2_HIGHCPU_8' +timeout: 1200s diff --git a/harnesses/copilot/config.yaml b/harnesses/copilot/config.yaml new file mode 100644 index 000000000..81d1b4167 --- /dev/null +++ b/harnesses/copilot/config.yaml @@ -0,0 +1,102 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +harness: copilot +image: scion-copilot:latest +user: scion + +provisioner: + type: container-script + interface_version: 1 + command: ["python3", "/home/scion/.scion/harness/provision.py"] + timeout: 30s + lifecycle_events: + - pre-start + required_image_tools: + - python3 + +config_dir: .copilot +skills_dir: .copilot/skills +interrupt_key: C-c +instructions_file: .github/copilot-instructions.md +system_prompt_mode: prepend_to_instructions + +model_aliases: + small: claude-haiku-4.5 + medium: claude-sonnet-4.5 + large: claude-opus-4.8 + +command: + base: ["copilot", "--no-auto-update", "--no-banner", "--no-remote", "--no-color", + "--allow-all"] + resume_flag: "--continue" + task_flag: "-i" + task_position: after_base_args + +env_template: + SCION_AGENT_NAME: "{{ .AgentName }}" + COPILOT_AUTO_UPDATE: "false" + NO_COLOR: "1" + CI: "1" + +mcp: + global_config_file: .copilot/mcp-config.json + global_config_path: mcpServers + transport_field: type + transport_map: + stdio: local + sse: http + streamable-http: http + +capabilities: + limits: + max_turns: { support: "no", reason: "Copilot CLI has no hook dialect for turn events" } + max_model_calls: { support: "no", reason: "Copilot CLI has no hook dialect for model events" } + max_duration: { support: "yes" } + telemetry: + enabled: { support: "no", reason: "Copilot telemetry config surface is undocumented" } + native_emitter: { support: "no" } + prompts: + system_prompt: { support: "partial", reason: "System prompt is prepended to copilot-instructions.md" } + agent_instructions: { support: "yes" } + auth: + api_key: { support: "yes" } + auth_file: { support: "no", reason: "OAuth capture not implemented in initial version" } + oauth_token: { support: "no" } + vertex_ai: { support: "no", reason: "Copilot uses GitHub auth, not GCP" } + mcp: + stdio: { support: "yes" } + sse: { support: "yes", reason: "Copilot maps SSE to its http transport type" } + streamable_http: { support: "yes" } + project_scope: { support: "no", reason: "Project-scoped MCP (.mcp.json) not implemented; entries demoted to global" } + +no_auth: + behavior: drop-to-shell + message: | + This agent started without credentials. + Run: copilot login + Complete the interactive login flow. + Then run: python3 /home/scion/.scion/harness/capture_auth.py + +auth: + default_type: api-key + types: + api-key: + required_env: + - any_of: ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + autodetect: + env: + COPILOT_GITHUB_TOKEN: api-key + GH_TOKEN: api-key + GITHUB_TOKEN: api-key diff --git a/harnesses/copilot/home/.bashrc b/harnesses/copilot/home/.bashrc new file mode 100644 index 000000000..3f70cd99b --- /dev/null +++ b/harnesses/copilot/home/.bashrc @@ -0,0 +1,4 @@ +# scion agent bashrc +# Source scion environment (written by sciontool init) +[ -f ~/.scion/scion-env ] && . ~/.scion/scion-env +alias c="copilot" diff --git a/harnesses/copilot/home/.copilot/settings.json b/harnesses/copilot/home/.copilot/settings.json new file mode 100644 index 000000000..d9bcf8d38 --- /dev/null +++ b/harnesses/copilot/home/.copilot/settings.json @@ -0,0 +1,3 @@ +{ + "autoUpdate": false +} diff --git a/harnesses/copilot/provision.py b/harnesses/copilot/provision.py new file mode 100644 index 000000000..9b2d4efe2 --- /dev/null +++ b/harnesses/copilot/provision.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Copilot container-side provisioner. + +Runs inside the agent container during the pre-start lifecycle hook, invoked +by `sciontool harness provision --manifest ...`. The host-side +ContainerScriptHarness has already: + + * Staged this script and config.yaml under $HOME/.scion/harness/. + * Written inputs/auth-candidates.json with the env-var names + paths to + secret-value files under $HOME/.scion/harness/secrets/. + * Written inputs/instructions.md with the composed agent instructions + (system prompt prepended when system_prompt_mode is prepend_to_instructions). + * Written inputs/mcp-servers.json describing the MCP servers to configure. + +This script's job: + + 1. Determine which auth method to use, honoring an explicit selection if + present and otherwise applying precedence: + COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN. + 2. For api-key methods, read the secret value from the staged + secrets/ file and write it to outputs/env.json as + {"COPILOT_GITHUB_TOKEN": ""}. Unlike Codex, Copilot reads auth + from environment variables, not from a config file. + 3. Write outputs/resolved-auth.json describing the chosen method. + 4. Copy staged instructions to .github/copilot-instructions.md in the + workspace, using managed block markers to protect Scion-injected content. + 5. Translate MCP servers to Copilot's native format in + ~/.copilot/mcp-config.json (stdio→local, sse/streamable-http→http). + 6. Ensure ~/.copilot/settings.json has sane defaults. + +The script is stdlib-only; no external dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + import scion_harness # type: ignore[import-not-found] +except ImportError: + scion_harness = None # type: ignore[assignment] + +VALID_AUTH_TYPES = ("api-key",) + +# Managed block markers for the instructions file. +MANAGED_BEGIN = "" +MANAGED_END = "" + +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_UNSUPPORTED = 2 + + +def _expand(path: str) -> str: + """Expand ~ and $HOME in a container path.""" + return os.path.expanduser(os.path.expandvars(path)) + + +def _load_json(path: str) -> Any: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def _write_json(path: str, payload: Any) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, path) + + +def _present_env_keys(candidates: dict[str, Any]) -> set[str]: + """Names of auth env vars staged by the host as candidates.""" + raw = candidates.get("env_vars") or [] + return {str(k) for k in raw if isinstance(k, str)} + + +def _env_secret_files(candidates: dict[str, Any]) -> dict[str, str]: + """Map of env-var name -> container path of its 0600 secret value file.""" + raw = candidates.get("env_secret_files") or {} + out: dict[str, str] = {} + if not isinstance(raw, dict): + return out + for k, v in raw.items(): + if isinstance(k, str) and isinstance(v, str) and v: + out[k] = v + return out + + +def _read_secret(env_secret_files: dict[str, str], name: str) -> str: + """Read the 0600 secret value file for an env var. Returns "" on miss.""" + path = env_secret_files.get(name) + if not path: + return "" + real = _expand(path) + try: + with open(real, "r", encoding="utf-8") as f: + return f.read().rstrip("\r\n") + except OSError: + return "" + + +def _select_auth_method( + explicit: str, + env_keys: set[str], +) -> tuple[str, str]: + """Pick an auth method. + + Returns (method, env_key). env_key is the chosen env var name when + method == 'api-key'. Raises ValueError on no-creds. + """ + has_copilot = "COPILOT_GITHUB_TOKEN" in env_keys + has_gh = "GH_TOKEN" in env_keys + has_github = "GITHUB_TOKEN" in env_keys + + if explicit: + if explicit not in VALID_AUTH_TYPES: + raise ValueError( + f"copilot: unknown auth type {explicit!r}; valid types are: " + f"{', '.join(VALID_AUTH_TYPES)}" + ) + if explicit == "api-key": + if has_copilot: + return "api-key", "COPILOT_GITHUB_TOKEN" + if has_gh: + return "api-key", "GH_TOKEN" + if has_github: + return "api-key", "GITHUB_TOKEN" + raise ValueError( + "copilot: auth type 'api-key' selected but no token found; " + "set COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN" + ) + + if has_copilot: + return "api-key", "COPILOT_GITHUB_TOKEN" + if has_gh: + return "api-key", "GH_TOKEN" + if has_github: + return "api-key", "GITHUB_TOKEN" + + raise ValueError( + "copilot: no valid auth method found; set COPILOT_GITHUB_TOKEN, " + "GH_TOKEN, or GITHUB_TOKEN with a fine-grained PAT that has " + '"Copilot Requests" permission' + ) + + +# --- Instructions projection ------------------------------------------------ + + +def _project_instructions(bundle: str, workspace: str) -> None: + """Copy staged instructions to .github/copilot-instructions.md. + + Uses managed block markers so Scion-injected content is identifiable + and preserved across reprovisions without clobbering user edits outside + the managed block. + """ + src = os.path.join(bundle, "inputs", "instructions.md") + if not os.path.isfile(src): + return + + try: + with open(src, "r", encoding="utf-8") as f: + scion_content = f.read().strip() + except OSError: + return + + if not scion_content: + return + + managed_block = f"{MANAGED_BEGIN}\n{scion_content}\n{MANAGED_END}" + + dst = os.path.join(workspace, ".github", "copilot-instructions.md") + os.makedirs(os.path.dirname(dst), exist_ok=True) + + existing = "" + if os.path.isfile(dst): + try: + with open(dst, "r", encoding="utf-8") as f: + existing = f.read() + except OSError: + existing = "" + + 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" + + tmp = dst + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp, dst) + print( + f"copilot provision: projected instructions to {dst}", + file=sys.stderr, + ) + + +# --- MCP server reconciliation ---------------------------------------------- + + +def _translate_mcp_server(name: str, spec: dict[str, Any]) -> dict[str, Any] | None: + """Translate a universal MCPServerConfig into Copilot's native shape. + + Returns None on skip (unsupported transport). + """ + transport = (spec.get("transport") or "").strip() + + if transport == "stdio": + cmd = spec.get("command") + if not isinstance(cmd, str) or not cmd: + print( + f"copilot provision: mcp server {name!r}: " + "stdio transport missing command", + file=sys.stderr, + ) + return None + out: dict[str, Any] = {"type": "local", "command": cmd} + args = spec.get("args") or [] + if isinstance(args, list) and args: + out["args"] = [str(a) for a in args] + env = spec.get("env") + if isinstance(env, dict) and env: + out["env"] = {str(k): str(v) for k, v in env.items()} + return out + + if transport in ("sse", "streamable-http"): + url = spec.get("url") + if not isinstance(url, str) or not url: + print( + f"copilot provision: mcp server {name!r}: " + f"{transport} transport missing url", + file=sys.stderr, + ) + return None + out = {"type": "http", "url": url} + headers = spec.get("headers") + if isinstance(headers, dict) and headers: + out["headers"] = {str(k): str(v) for k, v in headers.items()} + return out + + print( + f"copilot provision: mcp server {name!r}: " + f"unsupported transport {transport!r}", + file=sys.stderr, + ) + return None + + +def _apply_mcp_servers(bundle: str) -> int: + """Write MCP servers to ~/.copilot/mcp-config.json. + + Returns the number of servers written. Failures are warnings, not errors. + """ + if scion_harness is not None: + try: + servers = scion_harness.read_mcp_servers(bundle) + except ValueError as exc: + print(f"copilot provision: {exc}", file=sys.stderr) + return 0 + else: + servers = _read_mcp_servers_inline(bundle) + + if not servers: + return 0 + + mcp_servers: dict[str, Any] = {} + for name in sorted(servers.keys()): + spec = servers[name] + if not isinstance(spec, dict): + continue + scope = (spec.get("scope") or "global").strip().lower() + if scope == "project": + print( + f"copilot provision: mcp server {name!r} requested project scope; " + "registering globally (project-scoped MCP not implemented)", + file=sys.stderr, + ) + translated = _translate_mcp_server(name, spec) + if translated is not None: + mcp_servers[name] = translated + + if not mcp_servers: + return 0 + + config_dir = _expand("~/.copilot") + try: + os.makedirs(config_dir, exist_ok=True) + except OSError as exc: + print( + f"copilot provision: could not create {config_dir}: {exc}", + file=sys.stderr, + ) + return 0 + + config_path = os.path.join(config_dir, "mcp-config.json") + payload = {"mcpServers": mcp_servers} + tmp = config_path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, config_path) + except OSError as exc: + print( + f"copilot provision: failed to write mcp-config.json: {exc}", + file=sys.stderr, + ) + return 0 + + print( + f"copilot provision: applied {len(mcp_servers)} mcp server(s)", + file=sys.stderr, + ) + return len(mcp_servers) + + +def _read_mcp_servers_inline(bundle: str) -> dict[str, dict[str, Any]]: + """Fallback when scion_harness import fails.""" + path = os.path.join(bundle, "inputs", "mcp-servers.json") + if not os.path.isfile(path): + return {} + try: + payload = _load_json(path) or {} + except (OSError, json.JSONDecodeError) as exc: + print( + f"copilot provision: invalid mcp-servers.json: {exc}", + file=sys.stderr, + ) + return {} + if not isinstance(payload, dict): + return {} + servers = payload.get("mcp_servers") or {} + if not isinstance(servers, dict): + return {} + return {str(k): v for k, v in servers.items() if isinstance(v, dict)} + + +# --- Settings ---------------------------------------------------------------- + + +def _ensure_settings() -> None: + """Ensure ~/.copilot/settings.json has sane defaults.""" + config_dir = _expand("~/.copilot") + os.makedirs(config_dir, exist_ok=True) + settings_path = os.path.join(config_dir, "settings.json") + + settings: dict[str, Any] = {} + if os.path.isfile(settings_path): + try: + settings = _load_json(settings_path) or {} + except (OSError, json.JSONDecodeError): + settings = {} + + defaults = { + "autoUpdate": False, + } + changed = False + for key, value in defaults.items(): + if key not in settings: + settings[key] = value + changed = True + + if changed: + tmp = settings_path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2, sort_keys=True) + f.write("\n") + os.replace(tmp, settings_path) + + +# --- Entry point ------------------------------------------------------------- + + +def _provision(manifest: dict[str, Any]) -> int: + bundle = manifest.get("harness_bundle_dir") or "$HOME/.scion/harness" + bundle = _expand(bundle) + inputs_dir = os.path.join(bundle, "inputs") + + auth_candidates_path = os.path.join(inputs_dir, "auth-candidates.json") + 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 + + explicit = str(candidates.get("explicit_type") or "").strip() + env_keys = _present_env_keys(candidates) + secret_files = _env_secret_files(candidates) + + harness_cfg = manifest.get("harness_config") or {} + no_auth_cfg = harness_cfg.get("no_auth") or {} + no_auth_behavior = str(no_auth_cfg.get("behavior") or "").strip() + + if not candidates and no_auth_behavior: + print( + f"copilot provision: no-auth mode (behavior={no_auth_behavior}), " + "skipping auth setup", + file=sys.stderr, + ) + method = "none" + env_key = "" + else: + try: + method, env_key = _select_auth_method(explicit, env_keys) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return EXIT_ERROR + + env_payload: dict[str, Any] = {} + if method == "api-key": + secret = _read_secret(secret_files, env_key) + if not secret: + print( + f"copilot provision: chose api-key ({env_key}) but no secret " + "value was staged at the recorded path; check ApplyAuthSettings", + file=sys.stderr, + ) + return EXIT_ERROR + env_payload["COPILOT_GITHUB_TOKEN"] = secret + + # Outputs. + outputs = manifest.get("outputs") or {} + env_out = _expand( + outputs.get("env") or os.path.join(bundle, "outputs", "env.json") + ) + auth_out = _expand( + outputs.get("resolved_auth") + or os.path.join(bundle, "outputs", "resolved-auth.json") + ) + + resolved_payload: dict[str, Any] = { + "schema_version": 1, + "harness": "copilot", + "method": method, + "explicit_type": explicit or None, + } + if method == "api-key": + resolved_payload["env_var"] = env_key + + try: + _write_json(auth_out, resolved_payload) + _write_json(env_out, env_payload) + except OSError as exc: + print( + f"copilot provision: failed to write outputs: {exc}", + file=sys.stderr, + ) + return EXIT_ERROR + + # Project instructions to .github/copilot-instructions.md. + workspace = os.environ.get("SCION_WORKSPACE") or "/workspace" + try: + _project_instructions(bundle, workspace) + except OSError as exc: + print( + f"copilot provision: failed to project instructions: {exc}", + file=sys.stderr, + ) + + # Apply MCP servers. Failures are warnings, not provisioning errors. + _apply_mcp_servers(bundle) + + # Ensure settings have sane defaults. + try: + _ensure_settings() + except OSError as exc: + print( + f"copilot provision: failed to write settings: {exc}", + file=sys.stderr, + ) + + print(f"copilot provision: method={method}", file=sys.stderr) + return EXIT_OK + + +def _dispatch(manifest: dict[str, Any]) -> int: + command = str(manifest.get("command") or "provision") + if command == "provision": + return _provision(manifest) + print( + f"copilot provision: unsupported command {command!r}", + file=sys.stderr, + ) + return EXIT_UNSUPPORTED + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Copilot container-side provisioner" + ) + parser.add_argument( + "--manifest", + help="Path to the staged manifest.json " + "(defaults to $HOME/.scion/harness/manifest.json)", + default=None, + ) + args = parser.parse_args() + + manifest_path = args.manifest + if not manifest_path: + home = os.environ.get("HOME") or os.path.expanduser("~") + manifest_path = os.path.join(home, ".scion", "harness", "manifest.json") + + try: + manifest = _load_json(manifest_path) + except FileNotFoundError: + print( + f"copilot provision: manifest not found at {manifest_path}", + file=sys.stderr, + ) + return EXIT_ERROR + except (OSError, json.JSONDecodeError) as exc: + print( + f"copilot provision: failed to load manifest {manifest_path}: {exc}", + file=sys.stderr, + ) + return EXIT_ERROR + + if not isinstance(manifest, dict): + print("copilot provision: manifest is not an object", file=sys.stderr) + return EXIT_ERROR + + return _dispatch(manifest) + + +if __name__ == "__main__": + sys.exit(main())