From a7d5732b662d014449ccc1d242de2c37527964f6 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Sun, 31 May 2026 16:43:57 +0000 Subject: [PATCH 1/3] Migrate Claude harness from builtin to container-script provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Claude harness from the compiled-in builtin provisioner to the extensible container-script model, matching what OpenCode and Codex already use. This is the first concrete step in retiring the builtin provisioner path (refs #100). The container-side provision.py handles: - Auth resolution with Claude's 4-way precedence (API key → OAuth token → auth-file → Vertex AI) - API key pre-approval fingerprint in .claude.json - Project workspace path setup in .claude.json - MCP server translation via scion_harness helper - Auth env var overlay output for the runtime The compiled ClaudeCode harness is kept intact as a fallback for existing installations that haven't upgraded their harness-config. Includes parity tests and Python integration tests that verify the script produces identical outputs to the compiled harness. --- pkg/harness/claude/embeds/config.yaml | 17 +- pkg/harness/claude/embeds/provision.py | 532 +++++++++++++++ pkg/harness/claude_parity_test.go | 886 +++++++++++++++++++++++++ 3 files changed, 1434 insertions(+), 1 deletion(-) create mode 100644 pkg/harness/claude/embeds/provision.py create mode 100644 pkg/harness/claude_parity_test.go diff --git a/pkg/harness/claude/embeds/config.yaml b/pkg/harness/claude/embeds/config.yaml index 69afa779a..6f50cf4d9 100644 --- a/pkg/harness/claude/embeds/config.yaml +++ b/pkg/harness/claude/embeds/config.yaml @@ -15,9 +15,20 @@ harness: claude image: scion-claude:latest user: scion +# provisioner.type is container-script so the container-side provision.py +# handles auth resolution, API key pre-approval, project path setup, MCP +# server translation, and other harness-native config writes. Existing +# installations that still have type: builtin can run +# `scion harness-config upgrade claude --activate-script` to migrate. provisioner: - type: builtin + 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: .claude skills_dir: .claude/skills interrupt_key: Escape @@ -48,6 +59,10 @@ capabilities: auth_file: { support: "yes" } oauth_token: { support: "yes" } vertex_ai: { support: "yes" } + mcp: + stdio: { support: "yes" } + sse: { support: "yes" } + streamable_http: { support: "yes" } auth: default_type: api-key types: diff --git a/pkg/harness/claude/embeds/provision.py b/pkg/harness/claude/embeds/provision.py new file mode 100644 index 000000000..53942b668 --- /dev/null +++ b/pkg/harness/claude/embeds/provision.py @@ -0,0 +1,532 @@ +#!/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. +"""Claude Code 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/. + * Mounted any auth file (e.g. ~/.claude/.credentials.json) at the declared + container_path, when auth-file mode is in use. + * Mounted ADC credentials when vertex-ai mode is in use. + +This script's job mirrors the compiled ClaudeCode harness: + + 1. Determine which auth method Claude Code will use, honoring an explicit + selection if present and otherwise applying the same precedence as the + compiled harness: + ANTHROPIC_API_KEY > CLAUDE_CODE_OAUTH_TOKEN > auth-file > vertex-ai. + 2. For api-key auth, pre-approve the key by writing the last 20 chars of the + API key as a fingerprint in .claude.json's customApiKeyResponses so + Claude Code does not prompt for confirmation. + 3. Update .claude.json project paths to point at the container workspace. + 4. Translate universal MCP servers into Claude Code's native mcpServers + format in .claude.json. + 5. Write outputs/resolved-auth.json describing the chosen method. + 6. Write outputs/env.json with env vars to project into the harness process + (e.g. ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, or Vertex AI vars). + +The script is intentionally stdlib-only so it works on any container image +that ships python3 (declared in config.yaml's required_image_tools). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +# Add the bundle dir to sys.path so we can import the staged scion_harness +# helper module (sibling file of this script). +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] + +CLAUDE_JSON_FILE = "~/.claude.json" +CLAUDE_AUTH_FILE = "~/.claude/.credentials.json" +ADC_FILE = "~/.config/gcloud/application_default_credentials.json" + +VALID_AUTH_TYPES = ("api-key", "oauth-token", "auth-file", "vertex-ai") + +# Exit codes mirror the contract documented in the design doc: +# 0 = success +# 1 = error (stderr is captured and surfaced) +# 2 = unsupported command (treated as no-op for optional operations) +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 _present_file_paths(candidates: dict[str, Any]) -> list[str]: + """Container paths of auth files mounted by the host as candidates.""" + raw = candidates.get("files") or [] + out: list[str] = [] + for entry in raw: + if isinstance(entry, dict): + cp = entry.get("container_path") + if isinstance(cp, str) and cp: + out.append(cp) + return out + + +def _env_secret_files(candidates: dict[str, Any]) -> dict[str, str]: + """Map of env-var name -> path to secret value file staged by the host.""" + raw = candidates.get("env_secret_files") or {} + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items() if isinstance(k, str) and isinstance(v, str)} + + +def _read_secret(secret_files: dict[str, str], env_name: str) -> str: + """Read the secret value from a staged secret file. Returns empty string if unavailable.""" + path = secret_files.get(env_name, "") + if not path: + return "" + expanded = _expand(path) + try: + with open(expanded, "r", encoding="utf-8") as f: + return f.read().strip() + except OSError: + return "" + + +def _auth_file_present(file_paths: list[str], target: str) -> bool: + """Return True if the auth file is mounted or already on disk.""" + if any(_expand(p) == _expand(target) for p in file_paths): + return True + return os.path.isfile(_expand(target)) + + +def _select_auth_method( + explicit: str, + env_keys: set[str], + file_paths: list[str], +) -> tuple[str, str]: + """Pick an auth method. + + Returns (method, env_key_or_empty). env_key is the chosen API key env var + name when method == 'api-key', else "". Raises ValueError on no-creds. + """ + has_anthropic = "ANTHROPIC_API_KEY" in env_keys + has_oauth = "CLAUDE_CODE_OAUTH_TOKEN" in env_keys + has_authfile = _auth_file_present(file_paths, CLAUDE_AUTH_FILE) + has_gcp_project = "GOOGLE_CLOUD_PROJECT" in env_keys + has_gcp_region = "GOOGLE_CLOUD_REGION" in env_keys + has_adc = _auth_file_present(file_paths, ADC_FILE) + + if explicit: + if explicit not in VALID_AUTH_TYPES: + raise ValueError( + f"claude: unknown auth type {explicit!r}; valid types are: " + f"{', '.join(VALID_AUTH_TYPES)}" + ) + if explicit == "api-key": + if has_anthropic: + return "api-key", "ANTHROPIC_API_KEY" + raise ValueError( + "claude: auth type 'api-key' selected but no API key found; " + "set ANTHROPIC_API_KEY" + ) + if explicit == "oauth-token": + if has_oauth: + return "oauth-token", "CLAUDE_CODE_OAUTH_TOKEN" + raise ValueError( + "claude: auth type 'oauth-token' selected but no OAuth token found; " + "set CLAUDE_CODE_OAUTH_TOKEN (generate with `claude setup-token`)" + ) + if explicit == "auth-file": + if not has_authfile: + raise ValueError( + "claude: auth type 'auth-file' selected but no credentials file " + f"found; expected {CLAUDE_AUTH_FILE}" + ) + return "auth-file", "" + if explicit == "vertex-ai": + if not has_gcp_project or not has_gcp_region: + raise ValueError( + "claude: auth type 'vertex-ai' selected but GOOGLE_CLOUD_PROJECT " + "and/or GOOGLE_CLOUD_REGION not set" + ) + return "vertex-ai", "" + + # Auto-detect precedence matches the compiled ClaudeCode harness: + # API key -> OAuth token -> credentials file -> Vertex AI + if has_anthropic: + return "api-key", "ANTHROPIC_API_KEY" + if has_oauth: + return "oauth-token", "CLAUDE_CODE_OAUTH_TOKEN" + if has_authfile: + return "auth-file", "" + if has_adc and has_gcp_project and has_gcp_region: + return "vertex-ai", "" + + raise ValueError( + "claude: no valid auth method found; set ANTHROPIC_API_KEY for direct API " + "access, CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or " + f"{CLAUDE_AUTH_FILE} for subscription auth, or provide ADC + " + "GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION for Vertex AI" + ) + + +def _apply_api_key_approval(claude_json_path: str, api_key: str) -> None: + """Pre-approve the API key in .claude.json. + + Writes the last 20 characters of the key as a fingerprint in + customApiKeyResponses.approved so Claude Code does not prompt for + confirmation. Mirrors ClaudeCode.ApplyAuthSettings in claude_code.go. + """ + if not api_key: + return + + fingerprint = api_key[-20:] if len(api_key) > 20 else api_key + + cfg: dict[str, Any] = {} + if os.path.isfile(claude_json_path): + try: + cfg = _load_json(claude_json_path) or {} + except (OSError, json.JSONDecodeError): + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + cfg["customApiKeyResponses"] = { + "approved": [fingerprint], + "rejected": [], + } + + _write_json(claude_json_path, cfg) + + +def _update_project_paths(claude_json_path: str, workspace: str) -> None: + """Update .claude.json project paths to point at the container workspace. + + Mirrors ClaudeCode.provisionClaudeJSON in claude_code.go. Takes the first + existing project entry's settings and re-keys it to the container workspace + path. If no project entries exist, creates a default settings map. + """ + cfg: dict[str, Any] = {} + if os.path.isfile(claude_json_path): + try: + cfg = _load_json(claude_json_path) or {} + except (OSError, json.JSONDecodeError): + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + projects = cfg.get("projects") + if not isinstance(projects, dict): + projects = {} + + # Grab the first existing project's settings as the template. + project_settings: Any = None + for v in projects.values(): + project_settings = v + break + + if project_settings is None: + project_settings = { + "allowedTools": [], + "mcpContextUris": [], + "mcpServers": {}, + "enabledMcpjsonServers": [], + "disabledMcpjsonServers": [], + "hasTrustDialogAccepted": True, + "projectOnboardingSeenCount": 1, + "hasClaudeMdExternalIncludesApproved": False, + "hasClaudeMdExternalIncludesWarningShown": False, + "exampleFiles": [], + } + + cfg["projects"] = {workspace: project_settings} + _write_json(claude_json_path, cfg) + + +def _apply_mcp_servers(bundle: str, workspace: str) -> int: + """Read inputs/mcp-servers.json and merge into .claude.json. + + Claude Code's MCP schema is nearly 1:1 with the universal format. We use + scion_harness.apply_mcp_servers_simple() when available, falling back to + inline logic otherwise. + + Returns the number of servers written. 0 if no MCP input is staged. + """ + if scion_harness is None: + servers = _read_mcp_servers_inline(bundle) + else: + try: + servers = scion_harness.read_mcp_servers(bundle) + except ValueError as exc: + print(f"claude provision: {exc}", file=sys.stderr) + return 0 + + if not servers: + return 0 + + # Claude Code's native MCP config lives in .claude.json under + # projects..mcpServers for project-scoped entries and + # mcpServers at the top level for global entries. The apply_mcp_servers_simple + # helper handles both via the mapping config. + if scion_harness is not None: + mcp_mapping = { + "transport_field": "type", + "transport_map": { + "stdio": "stdio", + "sse": "sse", + "streamable-http": "streamable-http", + }, + "global_config_file": _expand(CLAUDE_JSON_FILE), + "global_config_path": "mcpServers", + "project_config_file": _expand(CLAUDE_JSON_FILE), + "project_config_path": f"projects.{workspace}.mcpServers", + } + try: + count = scion_harness.apply_mcp_servers_simple(bundle, mcp_mapping, workspace) + except (OSError, ValueError) as exc: + print(f"claude provision: mcp merge failed: {exc}", file=sys.stderr) + return 0 + if count > 0: + print(f"claude provision: applied {count} mcp server(s)", file=sys.stderr) + return count + + # Inline fallback when scion_harness is not available. + claude_json_path = _expand(CLAUDE_JSON_FILE) + cfg: dict[str, Any] = {} + if os.path.isfile(claude_json_path): + try: + cfg = _load_json(claude_json_path) or {} + except (OSError, json.JSONDecodeError): + cfg = {} + if not isinstance(cfg, dict): + cfg = {} + + # Merge all servers into mcpServers at the top level (global). + mcp_block = cfg.get("mcpServers") + if not isinstance(mcp_block, dict): + mcp_block = {} + for name, spec in servers.items(): + if not isinstance(spec, dict): + continue + # Claude's native MCP shape is close to 1:1; we pass through most fields. + native: dict[str, Any] = {} + for key, value in spec.items(): + if key == "transport": + native["type"] = value + elif key == "scope": + continue + else: + native[key] = value + mcp_block[name] = native + cfg["mcpServers"] = mcp_block + _write_json(claude_json_path, cfg) + + count = len(servers) + print(f"claude provision: applied {count} mcp server(s)", file=sys.stderr) + return count + + +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"claude 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)} + + +def _build_env_overlay(method: str, env_key: str) -> dict[str, str]: + """Build the env vars overlay for outputs/env.json. + + These mirror the env updates the compiled ClaudeCode.Provision() writes + into scion-agent.json. The container runtime reads env.json and projects + the vars into the harness process environment. + """ + if method == "api-key" and env_key: + return {env_key: f"${{{env_key}}}"} + if method == "oauth-token": + return {"CLAUDE_CODE_OAUTH_TOKEN": "${CLAUDE_CODE_OAUTH_TOKEN}"} + if method == "vertex-ai": + return { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_PROJECT_ID": "${GOOGLE_CLOUD_PROJECT}", + "CLOUD_ML_REGION": "${GOOGLE_CLOUD_REGION}", + } + # auth-file: no env updates needed. + return {} + + +def _provision(manifest: dict[str, Any]) -> int: + bundle = manifest.get("harness_bundle_dir") or "$HOME/.scion/harness" + bundle = _expand(bundle) + workspace = manifest.get("agent_workspace") or "/workspace" + + 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"claude 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) + file_paths = _present_file_paths(candidates) + secret_files = _env_secret_files(candidates) + + try: + method, env_key = _select_auth_method(explicit, env_keys, file_paths) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return EXIT_ERROR + + 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")) + + claude_json_path = _expand(CLAUDE_JSON_FILE) + + # 1. Update project paths in .claude.json. + try: + _update_project_paths(claude_json_path, workspace) + except OSError as exc: + print(f"claude provision: failed to update project paths: {exc}", file=sys.stderr) + return EXIT_ERROR + + # 2. For api-key auth, pre-approve the key so Claude Code doesn't prompt. + if method == "api-key" and env_key: + api_key_value = _read_secret(secret_files, env_key) + if api_key_value: + try: + _apply_api_key_approval(claude_json_path, api_key_value) + except OSError as exc: + print(f"claude provision: failed to write API key approval: {exc}", file=sys.stderr) + # Non-fatal: Claude Code will prompt but still work. + + # 3. Build resolved-auth output. + resolved_payload: dict[str, Any] = { + "schema_version": 1, + "harness": "claude", + "method": method, + "explicit_type": explicit or None, + } + if method == "api-key": + resolved_payload["env_var"] = env_key + elif method == "auth-file": + resolved_payload["auth_file"] = CLAUDE_AUTH_FILE + elif method == "vertex-ai": + resolved_payload["vertex_ai"] = True + + # 4. Build env overlay — mirrors ClaudeCode.Provision() env updates. + env_payload = _build_env_overlay(method, env_key) + + try: + _write_json(auth_out, resolved_payload) + _write_json(env_out, env_payload) + except OSError as exc: + print(f"claude provision: failed to write outputs: {exc}", file=sys.stderr) + return EXIT_ERROR + + # 5. Apply universal MCP servers if any. Failures are warnings, not + # provisioning errors — auth is the hard gate. + _apply_mcp_servers(bundle, workspace) + + print(f"claude 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"claude provision: unsupported command {command!r}", file=sys.stderr) + return EXIT_UNSUPPORTED + + +def main() -> int: + parser = argparse.ArgumentParser(description="Claude Code 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"claude provision: manifest not found at {manifest_path}", file=sys.stderr) + return EXIT_ERROR + except (OSError, json.JSONDecodeError) as exc: + print(f"claude provision: failed to load manifest {manifest_path}: {exc}", file=sys.stderr) + return EXIT_ERROR + + if not isinstance(manifest, dict): + print("claude provision: manifest is not an object", file=sys.stderr) + return EXIT_ERROR + + return _dispatch(manifest) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pkg/harness/claude_parity_test.go b/pkg/harness/claude_parity_test.go new file mode 100644 index 000000000..77787c6b5 --- /dev/null +++ b/pkg/harness/claude_parity_test.go @@ -0,0 +1,886 @@ +// 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. + +package harness + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/config" +) + +// seedClaudeDir seeds the embedded Claude harness-config into a temp dir +// using the same code path operators run during scion init / harness-config +// upgrade. It returns the absolute target dir so tests can inspect it. +func seedClaudeDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := config.SeedHarnessConfig(dir, &ClaudeCode{}, false); err != nil { + t.Fatalf("SeedHarnessConfig: %v", err) + } + return dir +} + +// TestClaudeEmbedsSeedRootSupportFiles verifies the new provision.py and +// the existing .claude.json land where they should: provision.py at the +// harness-config root, .claude.json under home/. +func TestClaudeEmbedsSeedRootSupportFiles(t *testing.T) { + dir := seedClaudeDir(t) + + // provision.py is a root-level support file. + provPath := filepath.Join(dir, "provision.py") + if _, err := os.Stat(provPath); err != nil { + t.Fatalf("expected provision.py at harness-config root: %v", err) + } + + // .claude.json is the harness-native settings file; it lives under home. + claudeJSON := filepath.Join(dir, "home", ".claude.json") + if _, err := os.Stat(claudeJSON); err != nil { + t.Fatalf("expected .claude.json under home/: %v", err) + } + + // config.yaml at the root must be valid and declare the container-script + // provisioner so the in-container provision.py runs during pre-start. + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatalf("LoadHarnessConfigDir: %v", err) + } + if hc.Config.Provisioner == nil { + t.Fatal("expected provisioner block in seeded config.yaml") + } + if hc.Config.Provisioner.Type != "container-script" { + t.Errorf("provisioner.type=%q want container-script", hc.Config.Provisioner.Type) + } + if len(hc.Config.Provisioner.Command) == 0 { + t.Error("expected provisioner.command in config.yaml") + } +} + +// TestClaudeActivateScriptIsIdempotent verifies that --activate-script is a +// no-op when the embedded default already declares container-script. +func TestClaudeActivateScriptIsIdempotent(t *testing.T) { + dir := seedClaudeDir(t) + + plan, err := config.UpgradeHarnessConfig(dir, &ClaudeCode{}, config.HarnessConfigUpgradeOptions{ + ActivateScript: true, + Now: func() time.Time { return time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC) }, + }) + if err != nil { + t.Fatalf("UpgradeHarnessConfig --activate-script: %v", err) + } + if plan.Changed { + t.Fatalf("expected no change (already container-script), got actions: %v", plan.Actions) + } + + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatalf("LoadHarnessConfigDir after activate: %v", err) + } + if hc.Config.Provisioner == nil || hc.Config.Provisioner.Type != "container-script" { + t.Fatalf("provisioner.type=%q want container-script", hc.Config.Provisioner.Type) + } + if len(plan.Backups) != 0 { + t.Fatalf("expected no backups for idempotent activate, got %v", plan.Backups) + } +} + +// TestClaudeContainerScriptHarnessParity asserts the ContainerScriptHarness +// wrapper produces the same observable command/env/capability/getter values as +// the compiled ClaudeCode harness for the embedded config. +func TestClaudeContainerScriptHarnessParity(t *testing.T) { + dir := seedClaudeDir(t) + + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatalf("LoadHarnessConfigDir: %v", err) + } + scripted, err := NewContainerScriptHarness(dir, hc.Config) + if err != nil { + t.Fatalf("NewContainerScriptHarness: %v", err) + } + builtin := &ClaudeCode{} + + // 1. Name must match. + if scripted.Name() != builtin.Name() { + t.Errorf("Name parity: scripted=%q builtin=%q", scripted.Name(), builtin.Name()) + } + if scripted.DefaultConfigDir() != builtin.DefaultConfigDir() { + t.Errorf("DefaultConfigDir: scripted=%q builtin=%q", scripted.DefaultConfigDir(), builtin.DefaultConfigDir()) + } + if scripted.SkillsDir() != builtin.SkillsDir() { + t.Errorf("SkillsDir: scripted=%q builtin=%q", scripted.SkillsDir(), builtin.SkillsDir()) + } + if scripted.GetInterruptKey() != builtin.GetInterruptKey() { + t.Errorf("GetInterruptKey: scripted=%q builtin=%q", scripted.GetInterruptKey(), builtin.GetInterruptKey()) + } + + // 2. GetCommand must match across the operative shapes. + cases := []struct { + name string + task string + resume bool + baseArg []string + }{ + {"resume_no_task", "", true, nil}, + {"task_only", "fix the bug", false, nil}, + {"task_with_base_args", "do it", false, []string{"--debug"}}, + } + for _, tc := range cases { + t.Run("GetCommand_"+tc.name, func(t *testing.T) { + gotS := scripted.GetCommand(tc.task, tc.resume, tc.baseArg) + gotB := builtin.GetCommand(tc.task, tc.resume, tc.baseArg) + if strings.Join(gotS, " ") != strings.Join(gotB, " ") { + t.Errorf("scripted=%v builtin=%v", gotS, gotB) + } + }) + } + + // 3. AdvancedCapabilities must report the same shape. + gotCaps := scripted.AdvancedCapabilities() + wantCaps := builtin.AdvancedCapabilities() + if gotCaps.Harness != wantCaps.Harness { + t.Errorf("Capabilities.Harness: scripted=%q builtin=%q", gotCaps.Harness, wantCaps.Harness) + } + if gotCaps.Limits.MaxDuration.Support != wantCaps.Limits.MaxDuration.Support { + t.Errorf("Capabilities.Limits.MaxDuration: scripted=%v builtin=%v", gotCaps.Limits.MaxDuration, wantCaps.Limits.MaxDuration) + } + if gotCaps.Auth.APIKey.Support != wantCaps.Auth.APIKey.Support { + t.Errorf("Capabilities.Auth.APIKey: scripted=%v builtin=%v", gotCaps.Auth.APIKey, wantCaps.Auth.APIKey) + } + if gotCaps.Auth.AuthFile.Support != wantCaps.Auth.AuthFile.Support { + t.Errorf("Capabilities.Auth.AuthFile: scripted=%v builtin=%v", gotCaps.Auth.AuthFile, wantCaps.Auth.AuthFile) + } + if gotCaps.Auth.OAuthToken.Support != wantCaps.Auth.OAuthToken.Support { + t.Errorf("Capabilities.Auth.OAuthToken: scripted=%v builtin=%v", gotCaps.Auth.OAuthToken, wantCaps.Auth.OAuthToken) + } + if gotCaps.Auth.VertexAI.Support != wantCaps.Auth.VertexAI.Support { + t.Errorf("Capabilities.Auth.VertexAI: scripted=%v builtin=%v", gotCaps.Auth.VertexAI, wantCaps.Auth.VertexAI) + } + if gotCaps.Prompts.SystemPrompt.Support != wantCaps.Prompts.SystemPrompt.Support { + t.Errorf("Capabilities.Prompts.SystemPrompt: scripted=%v builtin=%v", gotCaps.Prompts.SystemPrompt, wantCaps.Prompts.SystemPrompt) + } +} + +// TestClaudeContainerScriptHarnessStagesScript verifies Provision() copies +// the seeded provision.py into the agent bundle and writes a wrapper that +// targets sciontool harness provision. +func TestClaudeContainerScriptHarnessStagesScript(t *testing.T) { + dir := seedClaudeDir(t) + + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatalf("LoadHarnessConfigDir: %v", err) + } + scripted, err := NewContainerScriptHarness(dir, hc.Config) + if err != nil { + t.Fatalf("NewContainerScriptHarness: %v", err) + } + + agentHome := t.TempDir() + if err := scripted.Provision(context.Background(), "researcher", agentHome, agentHome, "/workspace"); err != nil { + t.Fatalf("Provision: %v", err) + } + + bundle := filepath.Join(agentHome, ".scion", "harness") + stagedScript := filepath.Join(bundle, "provision.py") + if _, err := os.Stat(stagedScript); err != nil { + t.Fatalf("provision.py not staged into bundle: %v", err) + } + + // The staged script must be byte-identical to the source. + stagedBytes, err := os.ReadFile(stagedScript) + if err != nil { + t.Fatal(err) + } + srcBytes, err := os.ReadFile(filepath.Join(dir, "provision.py")) + if err != nil { + t.Fatal(err) + } + if string(stagedBytes) != string(srcBytes) { + t.Error("staged provision.py differs from harness-config copy") + } + + wrapper := filepath.Join(agentHome, ".scion", "hooks", "pre-start.d", "20-harness-provision") + wrapperBytes, err := os.ReadFile(wrapper) + if err != nil { + t.Fatalf("hook wrapper missing: %v", err) + } + if !strings.Contains(string(wrapperBytes), "sciontool harness provision") { + t.Errorf("wrapper does not invoke sciontool harness provision: %s", wrapperBytes) + } +} + +// TestClaudeContainerScriptReconcilesMissingBundle verifies that calling +// Provision() on an agent home that lacks the container-script bundle stages +// the hook wrapper, provision.py, and manifest. +func TestClaudeContainerScriptReconcilesMissingBundle(t *testing.T) { + dir := seedClaudeDir(t) + + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatalf("LoadHarnessConfigDir: %v", err) + } + scripted, err := NewContainerScriptHarness(dir, hc.Config) + if err != nil { + t.Fatalf("NewContainerScriptHarness: %v", err) + } + + agentHome := t.TempDir() + + // Simulate an agent home created by the builtin ClaudeCode{} harness: + // the config dir exists but there is no .scion/harness/ bundle. + configDir := filepath.Join(agentHome, ".claude") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentHome, ".claude.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + // Confirm the hook wrapper does NOT exist yet. + hookWrapper := filepath.Join(agentHome, ".scion", "hooks", "pre-start.d", "20-harness-provision") + if _, err := os.Stat(hookWrapper); err == nil { + t.Fatal("hook wrapper should not exist before reconciliation") + } + + // Call Provision (the reconciliation path). + if err := scripted.Provision(context.Background(), "migrated-agent", agentHome, agentHome, "/workspace"); err != nil { + t.Fatalf("Provision (reconciliation): %v", err) + } + + // Hook wrapper must now exist. + wrapperBytes, err := os.ReadFile(hookWrapper) + if err != nil { + t.Fatalf("hook wrapper not staged after reconciliation: %v", err) + } + if !strings.Contains(string(wrapperBytes), "sciontool harness provision") { + t.Errorf("wrapper does not invoke sciontool harness provision: %s", wrapperBytes) + } + + // provision.py must be staged. + if _, err := os.Stat(filepath.Join(agentHome, ".scion", "harness", "provision.py")); err != nil { + t.Errorf("provision.py not staged: %v", err) + } + + // manifest.json must be present. + if _, err := os.Stat(filepath.Join(agentHome, ".scion", "harness", "manifest.json")); err != nil { + t.Errorf("manifest.json not staged: %v", err) + } + + // Pre-existing .claude.json must be preserved. + if _, err := os.Stat(filepath.Join(agentHome, ".claude.json")); err != nil { + t.Errorf("pre-existing .claude.json was removed: %v", err) + } +} + +// TestClaudeProvisionScript_Integration_HappyPath runs the actual Python +// script against a synthetic manifest and validates outputs. +func TestClaudeProvisionScript_Integration_HappyPath(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "secrets"), 0700); err != nil { + t.Fatal(err) + } + + // Seed a .claude.json for the script to update. + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"projects":{}}`), 0644); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + "platform": map[string]any{"goos": "linux", "goarch": "amd64"}, + } + manifestPath := filepath.Join(bundle, "manifest.json") + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "", + "resolved_method": "container-script", + "env_vars": []string{"ANTHROPIC_API_KEY"}, + "env_secret_files": map[string]string{}, + "files": []any{}, + } + candBytes, _ := json.MarshalIndent(candidates, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", manifestPath) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("provision script failed: %v\noutput: %s", err, out) + } + + // Verify resolved-auth.json + resolvedBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "resolved-auth.json")) + if err != nil { + t.Fatalf("resolved-auth.json missing: %v\nscript output: %s", err, out) + } + var resolved map[string]any + if err := json.Unmarshal(resolvedBytes, &resolved); err != nil { + t.Fatalf("resolved-auth.json invalid: %v", err) + } + if resolved["method"] != "api-key" { + t.Errorf("method=%v want api-key", resolved["method"]) + } + if resolved["env_var"] != "ANTHROPIC_API_KEY" { + t.Errorf("env_var=%v want ANTHROPIC_API_KEY", resolved["env_var"]) + } + + // Verify env.json contains the auth env var overlay. + envBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "env.json")) + if err != nil { + t.Fatalf("env.json missing: %v", err) + } + var envOverlay map[string]any + if err := json.Unmarshal(envBytes, &envOverlay); err != nil { + t.Fatalf("env.json invalid: %v", err) + } + if envOverlay["ANTHROPIC_API_KEY"] != "${ANTHROPIC_API_KEY}" { + t.Errorf("env.json ANTHROPIC_API_KEY=%v want ${ANTHROPIC_API_KEY}", envOverlay["ANTHROPIC_API_KEY"]) + } + + // Verify .claude.json was updated with project paths. + claudeData, err := os.ReadFile(filepath.Join(home, ".claude.json")) + if err != nil { + t.Fatalf(".claude.json missing: %v", err) + } + var claudeCfg map[string]any + if err := json.Unmarshal(claudeData, &claudeCfg); err != nil { + t.Fatalf(".claude.json invalid: %v", err) + } + projects, ok := claudeCfg["projects"].(map[string]any) + if !ok { + t.Fatal("projects not found in .claude.json") + } + if _, ok := projects["/workspace"]; !ok { + t.Errorf("expected project entry for /workspace, got keys: %v", func() []string { + keys := make([]string, 0, len(projects)) + for k := range projects { + keys = append(keys, k) + } + return keys + }()) + } +} + +// TestClaudeProvisionScript_Integration_NoCreds asserts the script exits +// non-zero with an actionable message when nothing is staged. +func TestClaudeProvisionScript_Integration_NoCreds(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestPath := filepath.Join(bundle, "manifest.json") + if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "", + "resolved_method": "container-script", + "env_vars": []string{}, + "files": []any{}, + } + candBytes, _ := json.Marshal(candidates) + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", manifestPath) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected non-zero exit, got success. output: %s", out) + } + if !strings.Contains(string(out), "no valid auth method") { + t.Errorf("expected actionable no-creds message, got: %s", out) + } +} + +// TestClaudeProvisionScript_Integration_APIKeyApproval runs the Python +// script with a staged API key secret and verifies the customApiKeyResponses +// fingerprint is written into .claude.json. +func TestClaudeProvisionScript_Integration_APIKeyApproval(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + secretsDir := filepath.Join(bundle, "secrets") + if err := os.MkdirAll(secretsDir, 0700); err != nil { + t.Fatal(err) + } + + // Seed .claude.json. + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"projects":{}}`), 0644); err != nil { + t.Fatal(err) + } + + // Stage a secret file with the API key value. + apiKey := "sk-ant-api03-ABCDEFGHIJ1234567890abcdefghij" + if err := os.WriteFile(filepath.Join(secretsDir, "ANTHROPIC_API_KEY"), []byte(apiKey), 0600); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + "platform": map[string]any{"goos": "linux", "goarch": "amd64"}, + } + manifestPath := filepath.Join(bundle, "manifest.json") + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "", + "resolved_method": "container-script", + "env_vars": []string{"ANTHROPIC_API_KEY"}, + "env_secret_files": map[string]string{ + "ANTHROPIC_API_KEY": filepath.Join(secretsDir, "ANTHROPIC_API_KEY"), + }, + "files": []any{}, + } + candBytes, _ := json.MarshalIndent(candidates, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", manifestPath) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("provision script failed: %v\noutput: %s", err, out) + } + + // Verify .claude.json has customApiKeyResponses with the fingerprint. + claudeData, err := os.ReadFile(filepath.Join(home, ".claude.json")) + if err != nil { + t.Fatalf(".claude.json missing: %v", err) + } + var claudeCfg map[string]any + if err := json.Unmarshal(claudeData, &claudeCfg); err != nil { + t.Fatalf(".claude.json invalid: %v", err) + } + + responses, ok := claudeCfg["customApiKeyResponses"].(map[string]any) + if !ok { + t.Fatal("customApiKeyResponses not found or wrong type") + } + approved, ok := responses["approved"].([]any) + if !ok || len(approved) != 1 { + t.Fatalf("expected 1 approved entry, got %v", responses["approved"]) + } + // Last 20 chars of the key. + wantFingerprint := apiKey[len(apiKey)-20:] + if approved[0] != wantFingerprint { + t.Errorf("approved fingerprint = %q, want %q", approved[0], wantFingerprint) + } +} + +// TestClaudeProvisionScript_Integration_VertexAI verifies the script produces +// the correct env overlay for Vertex AI auth. +func TestClaudeProvisionScript_Integration_VertexAI(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + + // Seed .claude.json and create ADC file so vertex-ai detection works. + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"projects":{}}`), 0644); err != nil { + t.Fatal(err) + } + adcDir := filepath.Join(home, ".config", "gcloud") + if err := os.MkdirAll(adcDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(adcDir, "application_default_credentials.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + "platform": map[string]any{"goos": "linux", "goarch": "amd64"}, + } + manifestPath := filepath.Join(bundle, "manifest.json") + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "vertex-ai", + "resolved_method": "container-script", + "env_vars": []string{"GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_REGION"}, + "files": []any{ + map[string]string{ + "container_path": "~/.config/gcloud/application_default_credentials.json", + }, + }, + } + candBytes, _ := json.MarshalIndent(candidates, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", manifestPath) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("provision script failed: %v\noutput: %s", err, out) + } + + // Verify resolved-auth.json. + resolvedBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "resolved-auth.json")) + if err != nil { + t.Fatalf("resolved-auth.json missing: %v", err) + } + var resolved map[string]any + if err := json.Unmarshal(resolvedBytes, &resolved); err != nil { + t.Fatalf("resolved-auth.json invalid: %v", err) + } + if resolved["method"] != "vertex-ai" { + t.Errorf("method=%v want vertex-ai", resolved["method"]) + } + + // Verify env.json has vertex-ai env vars. + envBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "env.json")) + if err != nil { + t.Fatalf("env.json missing: %v", err) + } + var envOverlay map[string]any + if err := json.Unmarshal(envBytes, &envOverlay); err != nil { + t.Fatalf("env.json invalid: %v", err) + } + if envOverlay["CLAUDE_CODE_USE_VERTEX"] != "1" { + t.Errorf("CLAUDE_CODE_USE_VERTEX=%v want 1", envOverlay["CLAUDE_CODE_USE_VERTEX"]) + } + if envOverlay["ANTHROPIC_VERTEX_PROJECT_ID"] != "${GOOGLE_CLOUD_PROJECT}" { + t.Errorf("ANTHROPIC_VERTEX_PROJECT_ID=%v want ${GOOGLE_CLOUD_PROJECT}", envOverlay["ANTHROPIC_VERTEX_PROJECT_ID"]) + } + if envOverlay["CLOUD_ML_REGION"] != "${GOOGLE_CLOUD_REGION}" { + t.Errorf("CLOUD_ML_REGION=%v want ${GOOGLE_CLOUD_REGION}", envOverlay["CLOUD_ML_REGION"]) + } +} + +// TestClaudeProvisionScript_Integration_MCP runs the script with a staged +// mcp-servers.json input and asserts it translates entries into Claude Code's +// native mcpServers shape in .claude.json. +func TestClaudeProvisionScript_Integration_MCP(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + // Stage scion_harness.py next to provision.py so the import resolves. + if err := os.WriteFile(filepath.Join(dir, "scion_harness.py"), SharedHarnessHelperSource(), 0644); err != nil { + t.Fatal(err) + } + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "secrets"), 0700); err != nil { + t.Fatal(err) + } + // Stage the helper into the bundle too (mirrors production). + if err := os.WriteFile(filepath.Join(bundle, "scion_harness.py"), SharedHarnessHelperSource(), 0644); err != nil { + t.Fatal(err) + } + + // Seed .claude.json with an existing project entry. + claudeJSON := map[string]any{ + "projects": map[string]any{ + "/workspace": map[string]any{ + "allowedTools": []any{}, + "mcpServers": map[string]any{}, + "hasTrustDialogAccepted": true, + "projectOnboardingSeenCount": 1, + }, + }, + } + claudeBytes, _ := json.MarshalIndent(claudeJSON, "", " ") + if err := os.WriteFile(filepath.Join(home, ".claude.json"), claudeBytes, 0644); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + "platform": map[string]any{"goos": "linux", "goarch": "amd64"}, + } + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "manifest.json"), manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + // Auth candidates so auth phase succeeds. + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "", + "resolved_method": "container-script", + "env_vars": []string{"ANTHROPIC_API_KEY"}, + "files": []any{}, + } + candBytes, _ := json.MarshalIndent(candidates, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + // Stage MCP servers — exercise stdio and SSE. + mcp := map[string]any{ + "schema_version": 1, + "mcp_servers": map[string]any{ + "filesystem": map[string]any{ + "transport": "stdio", + "command": "mcp-filesystem", + "args": []string{"/workspace"}, + "env": map[string]string{"DEBUG": "true"}, + }, + "remote_api": map[string]any{ + "transport": "sse", + "url": "http://localhost:8080/mcp/sse", + "headers": map[string]string{"Authorization": "Bearer xyz"}, + }, + }, + } + mcpBytes, _ := json.MarshalIndent(mcp, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "mcp-servers.json"), mcpBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", filepath.Join(bundle, "manifest.json")) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("provision script failed: %v\noutput: %s", err, out) + } + + // Read the updated .claude.json. + data, err := os.ReadFile(filepath.Join(home, ".claude.json")) + if err != nil { + t.Fatalf(".claude.json not readable: %v", err) + } + var cfg map[string]any + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf(".claude.json invalid JSON: %v", err) + } + + // MCP servers should be merged into mcpServers at the top level. + mcpBlock, ok := cfg["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("mcpServers block missing or wrong type: %v", cfg["mcpServers"]) + } + + // filesystem: stdio -> stdio with command/args/env. + fs, ok := mcpBlock["filesystem"].(map[string]any) + if !ok { + t.Fatal("filesystem entry missing from mcpServers") + } + if fs["type"] != "stdio" { + t.Errorf("filesystem type=%v want stdio", fs["type"]) + } + if fs["command"] != "mcp-filesystem" { + t.Errorf("filesystem command=%v want mcp-filesystem", fs["command"]) + } + + // remote_api: sse with url and headers. + remote, ok := mcpBlock["remote_api"].(map[string]any) + if !ok { + t.Fatal("remote_api entry missing from mcpServers") + } + if remote["type"] != "sse" { + t.Errorf("remote_api type=%v want sse", remote["type"]) + } + if remote["url"] != "http://localhost:8080/mcp/sse" { + t.Errorf("remote_api url=%v", remote["url"]) + } + + if !strings.Contains(string(out), "applied 2 mcp server(s)") { + t.Errorf("expected 'applied 2 mcp server(s)' summary, got: %s", out) + } +} + +// TestClaudeContainerScriptResolveAuthShape verifies the container-script +// ResolveAuth surfaces the values the script will need. +func TestClaudeContainerScriptResolveAuthShape(t *testing.T) { + dir := seedClaudeDir(t) + + hc, err := config.LoadHarnessConfigDir(dir) + if err != nil { + t.Fatal(err) + } + scripted, err := NewContainerScriptHarness(dir, hc.Config) + if err != nil { + t.Fatal(err) + } + + // Pass both an Anthropic key and an auth file; the container-script + // wrapper must surface BOTH so the in-container script can choose. + resolved, err := scripted.ResolveAuth(api.AuthConfig{ + AnthropicAPIKey: "sk-ant-xx", + ClaudeAuthFile: "/tmp/credentials.json", + }) + if err != nil { + t.Fatalf("ResolveAuth: %v", err) + } + if resolved.Method != "container-script" { + t.Errorf("Method=%q want container-script (final selection deferred to script)", resolved.Method) + } + if resolved.EnvVars["ANTHROPIC_API_KEY"] != "sk-ant-xx" { + t.Errorf("expected ANTHROPIC_API_KEY to flow through, got %v", resolved.EnvVars) + } + foundClaudeAuthFile := false + for _, f := range resolved.Files { + if f.SourcePath == "/tmp/credentials.json" && strings.HasSuffix(f.ContainerPath, ".credentials.json") { + foundClaudeAuthFile = true + } + } + if !foundClaudeAuthFile { + t.Errorf("expected Claude auth file in Files mapping, got %#v", resolved.Files) + } +} From 0c5b5817ab6cc5f42d3e016e7e1346a119300e16 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Sun, 31 May 2026 16:44:40 +0000 Subject: [PATCH 2/3] Add project log entry for Claude container-script migration --- ...05-31-claude-container-script-migration.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .design/project-log/2026-05-31-claude-container-script-migration.md diff --git a/.design/project-log/2026-05-31-claude-container-script-migration.md b/.design/project-log/2026-05-31-claude-container-script-migration.md new file mode 100644 index 000000000..8c995bf73 --- /dev/null +++ b/.design/project-log/2026-05-31-claude-container-script-migration.md @@ -0,0 +1,47 @@ +# Claude harness: builtin → container-script migration + +**Date**: 2026-05-31 +**Issue**: #100 +**PR**: #109 + +## What was done + +Migrated the Claude harness from the compiled-in builtin provisioner to the +container-script provisioning model. This is the first step in retiring the +builtin provisioner path — OpenCode and Codex were already on container-script. + +### Changes + +1. **config.yaml**: Changed `provisioner.type` from `builtin` to `container-script`, + added provisioner command/timeout/lifecycle config, added MCP capabilities block. + +2. **provision.py**: New container-side script (~350 lines) implementing: + - Auth resolution matching the compiled harness's 4-way precedence + - API key pre-approval fingerprint (mirrors `ApplyAuthSettings`) + - Project workspace path setup (mirrors `provisionClaudeJSON`) + - MCP server translation using shared `scion_harness.py` helper + - Auth env var overlay output + +3. **Parity tests**: 11 tests covering seed verification, parity with compiled + harness, bundle staging, reconciliation, and Python script integration. + +## Observations + +- The container-script provisioner's `ResolveAuth` returns method `"container-script"` + and passes ALL candidate credentials through, deferring final selection to the + in-container script. This is different from the builtin's `ResolveAuth` which + picks a single winner immediately. + +- The `scion_harness.apply_mcp_servers_simple()` helper works well for Claude's + MCP schema since it's nearly 1:1 with the universal format (unlike OpenCode + which needs custom translation to local/remote types). + +- The compiled `ClaudeCode` harness is kept intact as fallback. `resolve.go`'s + priority order checks container-script first, so the new path is used for + fresh installs. Existing installations on `type: builtin` continue using + the compiled harness until they run `scion harness-config upgrade claude --activate-script`. + +## Remaining work + +- Gemini harness migration (tracked in #100) +- Once all harnesses migrate: retire `newBuiltin()` and compiled provisioning methods From b31f758c097c425298047a0db800a4fe69688509 Mon Sep 17 00:00:00 2001 From: Preston Holmes Date: Mon, 1 Jun 2026 12:25:20 +0000 Subject: [PATCH 3/3] Fix Vertex AI auto-detect to work without local ADC file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: remove the has_adc requirement from the Vertex AI auto-detect path in provision.py. In GCP environments (GKE, Cloud Run, Compute Engine), containers authenticate via the metadata server using attached service accounts — no local ADC file is mounted. The GCP SDK handles this fallback natively, so requiring project + region is sufficient for auto-detection. Add TestClaudeProvisionScript_Integration_VertexAI_NoADC to verify the fix works for GCP service account environments. --- pkg/harness/claude/embeds/provision.py | 11 ++- pkg/harness/claude_parity_test.go | 94 ++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/pkg/harness/claude/embeds/provision.py b/pkg/harness/claude/embeds/provision.py index 53942b668..e79d81758 100644 --- a/pkg/harness/claude/embeds/provision.py +++ b/pkg/harness/claude/embeds/provision.py @@ -202,14 +202,19 @@ def _select_auth_method( return "oauth-token", "CLAUDE_CODE_OAUTH_TOKEN" if has_authfile: return "auth-file", "" - if has_adc and has_gcp_project and has_gcp_region: + if has_gcp_project and has_gcp_region: + # Accept vertex-ai when project + region are set, even without a local + # ADC file — in GCP environments (GKE, Cloud Run, Compute Engine) the + # metadata server provides credentials via the attached service account. + # The GCP SDK / Vertex AI client handles this fallback natively. return "vertex-ai", "" raise ValueError( "claude: no valid auth method found; set ANTHROPIC_API_KEY for direct API " "access, CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or " - f"{CLAUDE_AUTH_FILE} for subscription auth, or provide ADC + " - "GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION for Vertex AI" + f"{CLAUDE_AUTH_FILE} for subscription auth, or provide " + "GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION (with ADC or GCP service " + "account) for Vertex AI" ) diff --git a/pkg/harness/claude_parity_test.go b/pkg/harness/claude_parity_test.go index 77787c6b5..b2a6d5415 100644 --- a/pkg/harness/claude_parity_test.go +++ b/pkg/harness/claude_parity_test.go @@ -690,6 +690,100 @@ func TestClaudeProvisionScript_Integration_VertexAI(t *testing.T) { } } +// TestClaudeProvisionScript_Integration_VertexAI_NoADC verifies the script +// auto-detects vertex-ai auth even without a local ADC file — in GCP +// environments (GKE, Cloud Run, Compute Engine) the metadata server provides +// credentials via the attached service account. +func TestClaudeProvisionScript_Integration_VertexAI_NoADC(t *testing.T) { + pyPath, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not available; skipping script integration test") + } + + dir := seedClaudeDir(t) + scriptPath := filepath.Join(dir, "provision.py") + + home := t.TempDir() + bundle := filepath.Join(home, ".scion", "harness") + if err := os.MkdirAll(filepath.Join(bundle, "inputs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(bundle, "outputs"), 0755); err != nil { + t.Fatal(err) + } + + // Seed .claude.json but do NOT create ADC file — simulates GCP SA auth. + if err := os.WriteFile(filepath.Join(home, ".claude.json"), []byte(`{"projects":{}}`), 0644); err != nil { + t.Fatal(err) + } + + manifest := map[string]any{ + "schema_version": 1, + "command": "provision", + "agent_name": "test-agent", + "agent_home": home, + "agent_workspace": "/workspace", + "harness_bundle_dir": bundle, + "harness_config": map[string]any{"harness": "claude"}, + "inputs": map[string]any{}, + "outputs": map[string]any{ + "env": filepath.Join(bundle, "outputs", "env.json"), + "resolved_auth": filepath.Join(bundle, "outputs", "resolved-auth.json"), + }, + "platform": map[string]any{"goos": "linux", "goarch": "amd64"}, + } + manifestPath := filepath.Join(bundle, "manifest.json") + manifestBytes, _ := json.MarshalIndent(manifest, "", " ") + if err := os.WriteFile(manifestPath, manifestBytes, 0644); err != nil { + t.Fatal(err) + } + + // No explicit type, no ADC file — just project + region. Auto-detect + // should resolve to vertex-ai via metadata server fallback. + candidates := map[string]any{ + "schema_version": 1, + "explicit_type": "", + "resolved_method": "container-script", + "env_vars": []string{"GOOGLE_CLOUD_PROJECT", "GOOGLE_CLOUD_REGION"}, + "files": []any{}, + } + candBytes, _ := json.MarshalIndent(candidates, "", " ") + if err := os.WriteFile(filepath.Join(bundle, "inputs", "auth-candidates.json"), candBytes, 0644); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(pyPath, scriptPath, "--manifest", manifestPath) + cmd.Env = append(os.Environ(), "HOME="+home) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("provision script failed (GCP SA / no ADC): %v\noutput: %s", err, out) + } + + resolvedBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "resolved-auth.json")) + if err != nil { + t.Fatalf("resolved-auth.json missing: %v", err) + } + var resolved map[string]any + if err := json.Unmarshal(resolvedBytes, &resolved); err != nil { + t.Fatalf("resolved-auth.json invalid: %v", err) + } + if resolved["method"] != "vertex-ai" { + t.Errorf("method=%v want vertex-ai (auto-detected from project+region without ADC)", resolved["method"]) + } + + envBytes, err := os.ReadFile(filepath.Join(bundle, "outputs", "env.json")) + if err != nil { + t.Fatalf("env.json missing: %v", err) + } + var envOverlay map[string]any + if err := json.Unmarshal(envBytes, &envOverlay); err != nil { + t.Fatalf("env.json invalid: %v", err) + } + if envOverlay["CLAUDE_CODE_USE_VERTEX"] != "1" { + t.Errorf("CLAUDE_CODE_USE_VERTEX=%v want 1", envOverlay["CLAUDE_CODE_USE_VERTEX"]) + } +} + // TestClaudeProvisionScript_Integration_MCP runs the script with a staged // mcp-servers.json input and asserts it translates entries into Claude Code's // native mcpServers shape in .claude.json.