diff --git a/buildkitd.toml b/buildkitd.toml new file mode 100644 index 000000000..3b5d45809 --- /dev/null +++ b/buildkitd.toml @@ -0,0 +1,8 @@ +[registry."host.bridge.internal:5000"] +http = true + +[registry."localhost:5000"] +http = true + +[registry."host.docker.internal:5000"] +http = true diff --git a/changes.md b/changes.md new file mode 100644 index 000000000..fbc10250b --- /dev/null +++ b/changes.md @@ -0,0 +1,32 @@ +# Changes between `d59f9451` and `HEAD` + +## Features + +1. Full OpenCode Harness Support (event bridge via `scion-plugin.js`, `opencode` hook dialect, `config.yaml` capability updates, `provision.py` enhancements, heartbeat mechanism) +2. `OPENCODE_API_KEY` auth support across the auth pipeline (auto-detection, `AuthConfig`, `ResolveAuth`, `isAuthEnvKey`, `RequiredAuthEnvKeys`) +3. `"none"` auth type for harness authentication (CLI flags, schema, capabilities, auth secrets/keys validation) +4. `--source` CLI flag for specifying git source branch/tag/commit for agent workspaces (propagated through `StartOptions`, `ScionConfig`, Hub API types, `CreateWorktree`, runtime broker) +5. Default branch detection via `git ls-remote --symref` in the Hub (`pkg/hub/default_branch.go`) with remote probing and fallback chain +6. `manifest.json` written to object storage during resource bootstrap and hash-match paths (with `writeManifestToStorage` helper) +7. Harness config sync status display (`in-sync`, `local-outdated`, `hub-only`, `storage-stale`) with content hash comparison +8. `manifest.json` upload to signed URL during `scion harness-config sync` +9. `--force-harness-configs` server flag and `RefreshDefaultTemplates` that preserves user customizations by default +10. `disable_local_auth` settings field to skip local auth sources in broker-like scenarios +11. Docker network mode configuration via `settings.yaml` (`runtimes..docker.network`) mapped to `--network` flag +12. `scion exec` command for executing arbitrary commands inside agent containers (local and Hub modes) +13. WebSocket `MaxMessageSize` increased from 64KB to 10MB in `wsprotocol` and hub control channel +14. Hub `session-end` handler forwards `assistant_text` as outbound `"assistant-reply"` message +15. `internal/testgit` test helper package with `TestMain` setup in 7 packages to suppress macOS keychain prompts during tests +16. `opencode.jsonc` support in `mapEmbedFileToHomePath` and provision script +17. New scripts: `buildkitd.toml`, `local-scion-server.sh`, `proxy-host-to-docker.sh`, `test/Makefile`, `test/multi-agent-collaboration-tests.md` + +## Bugfixes + +1. Restored missing `StageCaptureAuthAssets` call in `pkg/agent/provision.go` that was inadvertently disabled by broken indentation +2. Fixed shebang lines (`#!/bin/bash` -> `#!/usr/bin/env bash`) in 3 image-build shell scripts for portability +3. Fixed inline config `Source` field not being propagated in `create.go` and `common.go` CLI paths +4. Removed redundant `GIT_ASKPASS=echo` env var in `remote_templates.go` (duplicated with `GIT_TERMINAL_PROMPT=0`) +5. Fixed `AuthSelectedType` not falling back to `Auth.DefaultType` when empty in `LoadHarnessConfigDir` +6. Consolidated duplicate default-branch fallback logic in `populateAgentConfig` into a single `resolveDefaultBranch` call +7. Resource validation now produces aggregate/summary issues instead of per-file noise when storage is empty or has mismatches +8. Server startup changed from `UpdateDefaultTemplates(true, ...)` to `RefreshDefaultTemplates(..., force=false)` to avoid clobbering user harness-config customizations diff --git a/cmd/common.go b/cmd/common.go index 6267742e8..2707865c6 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -65,6 +65,7 @@ var ( noAuth bool attach bool branch string + source string workspace string runtimeBrokerID string harnessConfigFlag string @@ -379,10 +380,10 @@ func RunAgent(cmd *cobra.Command, args []string, resume bool) error { // Validate --harness-auth value if harnessAuthFlag != "" { switch harnessAuthFlag { - case "api-key", "oauth-token", "auth-file", "vertex-ai": + case "api-key", "oauth-token", "auth-file", "vertex-ai", "none": // valid default: - return fmt.Errorf("invalid --harness-auth value %q: must be one of api-key, oauth-token, auth-file, vertex-ai", harnessAuthFlag) + return fmt.Errorf("invalid --harness-auth value %q: must be one of api-key, oauth-token, auth-file, vertex-ai, none", harnessAuthFlag) } } @@ -450,6 +451,7 @@ func RunAgent(cmd *cobra.Command, args []string, resume bool) error { // Apply inline config overrides to CLI options effectiveBranch := branch + effectiveSource := source effectiveTask := strings.TrimSpace(task) effectiveHarnessConfig := harnessConfigFlag effectiveHarnessAuth := harnessAuthFlag @@ -458,6 +460,9 @@ func RunAgent(cmd *cobra.Command, args []string, resume bool) error { if effectiveBranch == "" && inlineCfg.Branch != "" { effectiveBranch = inlineCfg.Branch } + if effectiveSource == "" && inlineCfg.Source != "" { + effectiveSource = inlineCfg.Source + } if effectiveTask == "" && inlineCfg.Task != "" { effectiveTask = inlineCfg.Task } @@ -500,6 +505,7 @@ func RunAgent(cmd *cobra.Command, args []string, resume bool) error { Detached: detached, NoAuth: noAuth, Branch: effectiveBranch, + Source: effectiveSource, Workspace: workspace, InlineConfig: inlineCfg, } diff --git a/cmd/create.go b/cmd/create.go index 593effbc6..4b00cdd58 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -50,10 +50,10 @@ arguments are provided, an empty prompt.md is created for later editing.`, // Validate --harness-auth value if harnessAuthFlag != "" { switch harnessAuthFlag { - case "api-key", "oauth-token", "auth-file", "vertex-ai": + case "api-key", "oauth-token", "auth-file", "vertex-ai", "none": // valid default: - return fmt.Errorf("invalid --harness-auth value %q: must be one of api-key, oauth-token, auth-file, vertex-ai", harnessAuthFlag) + return fmt.Errorf("invalid --harness-auth value %q: must be one of api-key, oauth-token, auth-file, vertex-ai, none", harnessAuthFlag) } } @@ -89,6 +89,7 @@ arguments are provided, an empty prompt.md is created for later editing.`, // Apply inline config overrides to CLI options effectiveBranch := branch + effectiveSource := source effectiveTask := task effectiveHarnessConfig := harnessConfigFlag effectiveImage := agentImage @@ -97,6 +98,9 @@ arguments are provided, an empty prompt.md is created for later editing.`, if effectiveBranch == "" && inlineCfg.Branch != "" { effectiveBranch = inlineCfg.Branch } + if effectiveSource == "" && inlineCfg.Source != "" { + effectiveSource = inlineCfg.Source + } if effectiveTask == "" && inlineCfg.Task != "" { effectiveTask = inlineCfg.Task } @@ -117,6 +121,7 @@ arguments are provided, an empty prompt.md is created for later editing.`, Image: effectiveImage, ProjectPath: projectPath, Branch: effectiveBranch, + Source: effectiveSource, Workspace: workspace, InlineConfig: inlineCfg, } @@ -322,12 +327,13 @@ func init() { createCmd.Flags().StringVarP(&templateName, "type", "t", "", "Template to use") createCmd.Flags().StringVarP(&agentImage, "image", "i", "", "Container image to use (overrides template)") createCmd.Flags().StringVarP(&branch, "branch", "b", "", "Git branch to use for the agent workspace") + createCmd.Flags().StringVar(&source, "source", "", "Source branch/tag/commit for the agent workspace (defaults to repo default branch)") createCmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Host path to mount as /workspace") createCmd.Flags().StringVar(&runtimeBrokerID, "broker", "", "Preferred runtime broker ID or name") createCmd.Flags().StringVar(&harnessConfigFlag, "harness-config", "", "Named harness configuration to use") createCmd.Flags().StringVar(&harnessConfigFlag, "harness", "h", "Named harness configuration to use (alias for --harness-config)") - createCmd.Flags().StringVar(&harnessAuthFlag, "harness-auth", "", "Override auth method for the harness (api-key, oauth-token, auth-file, vertex-ai)") + createCmd.Flags().StringVar(&harnessAuthFlag, "harness-auth", "", "Override auth method for the harness (api-key, oauth-token, auth-file, vertex-ai, none)") // Template resolution flags for Hub mode (Section 9.4) createCmd.Flags().BoolVar(&uploadTemplate, "upload-template", false, "Automatically upload local template to Hub if not found") diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 000000000..876dcfb81 --- /dev/null +++ b/cmd/exec.go @@ -0,0 +1,108 @@ +// 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 cmd + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/GoogleCloudPlatform/scion/pkg/api" + "github.com/GoogleCloudPlatform/scion/pkg/runtime" + "github.com/spf13/cobra" +) + +var execTimeout int + +// execCmd represents the exec command +var execCmd = &cobra.Command{ + Use: "exec -- [args...]", + Short: "Execute a command inside an agent container", + Long: `Execute a command inside a running agent's container. + +In hub mode, the command is dispatched through the hub to the runtime broker +that owns the agent. In local mode, the command runs directly on the local +container runtime (Docker, Podman, or Apple Container). + +This works across all runtime backends — the CLI abstracts away the +differences between docker exec, podman exec, container exec, and kubectl exec.`, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: getAgentNames, + RunE: func(cmd *cobra.Command, args []string) error { + agentName := api.Slugify(args[0]) + + // Everything after -- is the command to execute + command := args[1:] + if len(command) == 0 { + return fmt.Errorf("no command specified") + } + + // Check if Hub is enabled + hubCtx, err := CheckHubAvailabilityForAgent(projectPath, agentName, false) + if err != nil { + return err + } + + if hubCtx != nil { + return execViaHub(hubCtx, agentName, command) + } + + return execLocal(agentName, command) + }, +} + +func execLocal(agentName string, command []string) error { + rt := runtime.GetRuntime(projectPath, profile) + output, err := rt.Exec(context.Background(), agentName, command) + if err != nil { + return fmt.Errorf("failed to execute command in agent '%s': %w", agentName, err) + } + fmt.Print(output) + return nil +} + +func execViaHub(hubCtx *HubContext, agentName string, command []string) error { + PrintUsingHub(hubCtx.Endpoint) + + projectID, err := GetProjectID(hubCtx) + if err != nil { + return wrapHubError(err) + } + + timeout := execTimeout + if timeout <= 0 { + timeout = 30 + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + resp, err := hubCtx.Client.ProjectAgents(projectID).Exec(ctx, agentName, command, timeout) + if err != nil { + return wrapHubError(fmt.Errorf("failed to execute command in agent '%s': %w", agentName, err)) + } + + fmt.Print(resp.Output) + if resp.ExitCode != 0 { + os.Exit(resp.ExitCode) + } + return nil +} + +func init() { + execCmd.Flags().IntVarP(&execTimeout, "timeout", "t", 30, "Timeout in seconds for command execution") + rootCmd.AddCommand(execCmd) +} diff --git a/cmd/harness_config.go b/cmd/harness_config.go index 3977f9c27..601087f44 100644 --- a/cmd/harness_config.go +++ b/cmd/harness_config.go @@ -15,7 +15,9 @@ package cmd import ( + "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -26,6 +28,7 @@ import ( "github.com/GoogleCloudPlatform/scion/pkg/config" "github.com/GoogleCloudPlatform/scion/pkg/harness" "github.com/GoogleCloudPlatform/scion/pkg/hubclient" + "github.com/GoogleCloudPlatform/scion/pkg/transfer" "github.com/spf13/cobra" ) @@ -87,22 +90,51 @@ var harnessConfigListCmd = &cobra.Command{ Status: "active", }) if err == nil { - // Merge Hub results (avoid duplicates by name) - localNames := make(map[string]bool) - for _, e := range entries { - localNames[e.Name] = true + // Build hub map for comparison + hubMap := make(map[string]*hubclient.HarnessConfig) + for i := range hubResp.HarnessConfigs { + hubMap[hubResp.HarnessConfigs[i].Name] = &hubResp.HarnessConfigs[i] } - for _, hc := range hubResp.HarnessConfigs { - if !localNames[hc.Name] { - entries = append(entries, hcEntry{ - Name: hc.Name, - Harness: hc.Harness, - Source: "hub", - ID: hc.ID, - Status: hc.Status, - }) + + // Update local entries with hub status and validate storage + for i := range entries { + if hubHC, exists := hubMap[entries[i].Name]; exists { + entries[i].ID = hubHC.ID + entries[i].Status = hubHC.Status + + // Validate storage to check for stale content + report, err := hubCtx.Client.HarnessConfigs().Validate(context.Background(), hubHC.ID) + if err == nil && len(report.Issues) > 0 { + // Storage has issues + entries[i].Source = "local+hub (storage-stale)" + } else { + // Storage is valid, compare local vs DB + files, err := transfer.CollectFiles(entries[i].Path, nil) + if err == nil { + localHash := transfer.ComputeContentHash(files) + if localHash == hubHC.ContentHash { + entries[i].Source = "local+hub (in-sync)" + } else { + entries[i].Source = "local+hub (local-outdated)" + } + } else { + entries[i].Source = "local+hub (error)" + } + } + delete(hubMap, entries[i].Name) } } + + // Add hub-only entries + for name, hc := range hubMap { + entries = append(entries, hcEntry{ + Name: name, + Harness: hc.Harness, + Source: "hub-only", + ID: hc.ID, + Status: hc.Status, + }) + } } } } @@ -672,6 +704,18 @@ func syncHarnessConfigToHub(hubCtx *HubContext, name, localPath, scope, scopeID, } } + // Upload manifest.json to storage if ManifestURL is provided + if uploadResp.ManifestURL != "" { + fmt.Println("Uploading manifest.json...") + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to marshal manifest: %w", err) + } + if err := transfer.NewClient(nil).UploadFileWithMethod(ctx, uploadResp.ManifestURL, "PUT", nil, bytes.NewReader(manifestBytes)); err != nil { + return fmt.Errorf("failed to upload manifest.json: %w", err) + } + } + // Finalize fmt.Println("Finalizing harness-config...") hc, err := hubCtx.Client.HarnessConfigs().Finalize(ctx, hcID, manifest) diff --git a/cmd/sciontool/commands/hook.go b/cmd/sciontool/commands/hook.go index 5f503f531..cc23f7a17 100644 --- a/cmd/sciontool/commands/hook.go +++ b/cmd/sciontool/commands/hook.go @@ -92,7 +92,7 @@ func init() { rootCmd.AddCommand(hookCmd) hookCmd.Flags().StringVar(&hookDialect, "dialect", "claude", - "Harness dialect for event parsing (claude, gemini, codex)") + "Harness dialect for event parsing (claude, gemini, codex, opencode)") hookCmd.Flags().StringVar(&hookData, "data", "", "Additional data for subcommands") diff --git a/cmd/server.go b/cmd/server.go index f3f5e90c8..aad2a20d9 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -59,9 +59,13 @@ var ( // Server daemon flags serverStartForeground bool stopForce bool + serverNoBrowser bool // Hosted mode flag (replaces former "production" mode) hostedMode bool + + // Force overwrite harness-configs on startup + forceHarnessConfigs bool ) const ( @@ -268,6 +272,9 @@ func init() { // Runtime Broker auto-provide flag serverStartCmd.Flags().BoolVar(&serverAutoProvide, "auto-provide", false, "Automatically add runtime broker as provider for new projects") + // Harness-config force flag + serverStartCmd.Flags().BoolVar(&forceHarnessConfigs, "force-harness-configs", false, "Force overwrite of harness-config files on startup (discards user customizations)") + // Web Frontend flags serverStartCmd.Flags().BoolVar(&enableWeb, "enable-web", false, "Enable the web frontend") serverStartCmd.Flags().IntVar(&webPort, "web-port", 8080, "Web frontend port") diff --git a/cmd/server_foreground.go b/cmd/server_foreground.go index d6abfee44..2dfd528ac 100644 --- a/cmd/server_foreground.go +++ b/cmd/server_foreground.go @@ -97,7 +97,8 @@ func runServerStart(cmd *cobra.Command, args []string) error { // In workstation mode, refresh the default template and harness-configs // from the binary's embeds. Hosted mode bootstraps directly into the Hub // via BootstrapBundledResources, bypassing local ~/.scion materialization. - if err := config.UpdateDefaultTemplates(true, harness.EmbedOnlyHarnesses()); err != nil { + // The forceHarnessConfigs flag controls whether to overwrite user customizations. + if err := config.RefreshDefaultTemplates(harness.EmbedOnlyHarnesses(), forceHarnessConfigs); err != nil { log.Printf("Warning: failed to refresh default templates: %v", err) } } diff --git a/cmd/start.go b/cmd/start.go index 36f99440a..3ea58aad2 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -50,13 +50,15 @@ func init() { startCmd.Flags().StringVarP(&branch, "branch", "b", "", "Git branch to use for the agent workspace") + startCmd.Flags().StringVar(&source, "source", "", "Source branch/tag/commit for the agent workspace (defaults to repo default branch)") + startCmd.Flags().StringVarP(&workspace, "workspace", "w", "", "Host path to mount as /workspace") startCmd.Flags().StringVar(&runtimeBrokerID, "broker", "", "Preferred runtime broker ID or name") startCmd.Flags().StringVar(&harnessConfigFlag, "harness-config", "", "Named harness configuration to use") startCmd.Flags().StringVar(&harnessConfigFlag, "harness", "", "Named harness configuration to use (alias for --harness-config)") - startCmd.Flags().StringVar(&harnessAuthFlag, "harness-auth", "", "Override auth method for the harness (api-key, oauth-token, auth-file, vertex-ai)") + startCmd.Flags().StringVar(&harnessAuthFlag, "harness-auth", "", "Override auth method for the harness (api-key, oauth-token, auth-file, vertex-ai, none)") // Notification flag — on by default for Hub mode; use --no-notify to opt out startCmd.Flags().BoolVar(&startNoNotify, "no-notify", false, "Do not subscribe to notifications for the spawned agent") diff --git a/harnesses/opencode/.gitignore b/harnesses/opencode/.gitignore new file mode 100644 index 000000000..225fc6f66 --- /dev/null +++ b/harnesses/opencode/.gitignore @@ -0,0 +1 @@ +/__pycache__ diff --git a/harnesses/opencode/Dockerfile b/harnesses/opencode/Dockerfile index d1c53a225..f503f6995 100644 --- a/harnesses/opencode/Dockerfile +++ b/harnesses/opencode/Dockerfile @@ -19,6 +19,8 @@ FROM ${BASE_IMAGE} RUN mkdir -p /home/scion/.local/share/opencode && \ chown -R scion:scion /home/scion/.local +RUN mkdir -p /home/scion/.scion/harness + # Install OpenCode # RUN curl -fsSL https://opencode.ai/install | bash RUN npm install -g opencode-ai \ diff --git a/harnesses/opencode/config.yaml b/harnesses/opencode/config.yaml index 44ee98576..c9bfb0c94 100644 --- a/harnesses/opencode/config.yaml +++ b/harnesses/opencode/config.yaml @@ -48,12 +48,12 @@ command: task_position: before_base_args capabilities: limits: - max_turns: { support: "no", reason: "This harness has no hook dialect for turn events" } - max_model_calls: { support: "no", reason: "This harness has no hook dialect for model events" } + max_turns: { support: "yes", reason: "Supported via scion-plugin.js event bridge" } + max_model_calls: { support: "yes", reason: "Supported via scion-plugin.js event bridge" } max_duration: { support: "yes" } telemetry: enabled: { support: "yes" } - native_emitter: { support: "no", reason: "Native telemetry forwarding is not wired for this harness" } + native_emitter: { support: "yes", reason: "Forwarded via scion-plugin.js event bridge" } prompts: system_prompt: { support: "partial", reason: "System prompt is downgraded into AGENTS.md" } agent_instructions: { support: "yes" } @@ -62,11 +62,13 @@ capabilities: auth_file: { support: "yes" } oauth_token: { support: "no" } vertex_ai: { support: "yes" } + none: { support: "yes", reason: "No authentication required (e.g. local llama.cpp)" } mcp: stdio: { support: "yes" } sse: { support: "yes" } streamable_http: { support: "yes" } project_scope: { support: "no", reason: "OpenCode does not distinguish project-scoped MCP" } + resume: { support: "yes" } no_auth: behavior: drop-to-shell message: | @@ -78,7 +80,7 @@ auth: types: api-key: required_env: - - any_of: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] + - any_of: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENCODE_API_KEY"] auth-file: required_files: - name: OPENCODE_AUTH @@ -89,10 +91,13 @@ auth: required_env: - any_of: ["GOOGLE_CLOUD_PROJECT", "VERTEXAI_PROJECT"] - any_of: ["GOOGLE_CLOUD_REGION", "GOOGLE_CLOUD_LOCATION", "VERTEX_LOCATION"] + none: + required_env: [] autodetect: env: ANTHROPIC_API_KEY: api-key OPENAI_API_KEY: api-key + OPENCODE_API_KEY: api-key GOOGLE_CLOUD_PROJECT: vertex-ai VERTEXAI_PROJECT: vertex-ai GOOGLE_CLOUD_REGION: vertex-ai diff --git a/harnesses/opencode/provision.py b/harnesses/opencode/provision.py index 4475ad664..7216bf80d 100755 --- a/harnesses/opencode/provision.py +++ b/harnesses/opencode/provision.py @@ -19,20 +19,20 @@ ContainerScriptHarness has already: * Staged this script and config.yaml under $HOME/.scion/harness/. - * Projected available auth env vars into the container's launch environment - (so the OpenCode child process will see ANTHROPIC_API_KEY, OPENAI_API_KEY, - etc. — but `sciontool harness provision` strips them from THIS script's env - for containment, so we read the *names* of available creds from - inputs/auth-candidates.json instead of os.environ). + * Projected available auth env vars into the container's launch environment + (so the OpenCode child process will see ANTHROPIC_API_KEY, OPENAI_API_KEY, + OPENCODE_API_KEY, etc. — but `sciontool harness provision` strips them from + THIS script's env for containment, so we read the *names* of available + creds from inputs/auth-candidates.json instead of os.environ). * Mounted any auth file (e.g. ~/.local/share/opencode/auth.json) at the declared container_path. This script's job is therefore minimal: - 1. Determine which auth method OpenCode will use, honoring an explicit - selection if present and otherwise applying the same precedence as the - compiled OpenCode harness: - AnthropicAPIKey > OpenAIAPIKey > OpenCodeAuthFile. + 1. Determine which auth method OpenCode will use, honoring an explicit + selection if present and otherwise applying the same precedence as the + compiled OpenCode harness: + AnthropicAPIKey > OpenAIAPIKey > OpenCodeAPIKey > OpenCodeAuthFile. 2. Fail (exit 1) with an actionable message if no method is available. 3. Write outputs/resolved-auth.json describing the choice (for diagnostics and resume-time consistency). @@ -62,9 +62,10 @@ scion_harness = None # type: ignore[assignment] OPENCODE_AUTH_FILE = "~/.local/share/opencode/auth.json" -OPENCODE_CONFIG_FILE = "~/.config/opencode/opencode.json" +OPENCODE_CONFIG_DIR = "~/.config/opencode" +OPENCODE_CONFIG_CANDIDATES = ("opencode.jsonc", "opencode.json") -VALID_AUTH_TYPES = ("api-key", "auth-file", "vertex-ai") +VALID_AUTH_TYPES = ("api-key", "auth-file", "vertex-ai", "none") # Exit codes mirror the contract documented in the design doc: # 0 = success @@ -80,11 +81,44 @@ def _expand(path: str) -> str: return os.path.expanduser(os.path.expandvars(path)) +def _resolve_opencode_config_path() -> str: + """Resolve the OpenCode config file path. + + Prefers opencode.jsonc over opencode.json if present. Defaults to + opencode.json if neither exists. + """ + base = _expand(OPENCODE_CONFIG_DIR) + for name in OPENCODE_CONFIG_CANDIDATES: + candidate = os.path.join(base, name) + if os.path.isfile(candidate): + return candidate + return os.path.join(base, "opencode.json") + + +def _strip_jsonc_comments(text: str) -> str: + """Strip // and /* */ comments from JSONC text for json.loads parsing.""" + import re + + text = re.sub(r"//.*$", "", text, flags=re.MULTILINE) + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + return text + + def _load_json(path: str) -> Any: with open(path, "r", encoding="utf-8") as f: return json.load(f) +def _load_json_or_jsonc(path: str) -> Any: + """Load a JSON or JSONC file, stripping comments if needed.""" + with open(path, "r", encoding="utf-8") as f: + text = f.read() + try: + return json.loads(text) + except json.JSONDecodeError: + return json.loads(_strip_jsonc_comments(text)) + + def _write_json(path: str, payload: Any) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" @@ -165,10 +199,13 @@ def _select_auth_method( """ has_anthropic = "ANTHROPIC_API_KEY" in env_keys has_openai = "OPENAI_API_KEY" in env_keys + has_opencode = "OPENCODE_API_KEY" in env_keys has_authfile = _opencode_auth_file_present(file_paths) has_vertex_project = bool(env_keys & {"GOOGLE_CLOUD_PROJECT", "VERTEXAI_PROJECT"}) - has_vertex_location = bool(env_keys & {"GOOGLE_CLOUD_REGION", "GOOGLE_CLOUD_LOCATION", "VERTEX_LOCATION"}) + has_vertex_location = bool( + env_keys & {"GOOGLE_CLOUD_REGION", "GOOGLE_CLOUD_LOCATION", "VERTEX_LOCATION"} + ) # gcp_metadata_mode is not currently populated in auth-candidates.json by # the Go staging layer; this guard is reserved for future use. gcp_meta_mode = str(candidates.get("gcp_metadata_mode") or "").strip() @@ -186,9 +223,11 @@ def _select_auth_method( return "api-key", "ANTHROPIC_API_KEY" if has_openai: return "api-key", "OPENAI_API_KEY" + if has_opencode: + return "api-key", "OPENCODE_API_KEY" raise ValueError( "opencode: auth type 'api-key' selected but no API key found; " - "set ANTHROPIC_API_KEY or OPENAI_API_KEY" + "set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENCODE_API_KEY" ) if explicit == "auth-file": if not has_authfile: @@ -197,6 +236,8 @@ def _select_auth_method( f"found; expected {OPENCODE_AUTH_FILE}" ) return "auth-file", "" + if explicit == "none": + return "none", "" if explicit == "vertex-ai": if not has_vertex_project or not has_vertex_location: raise ValueError( @@ -216,15 +257,18 @@ def _select_auth_method( return "api-key", "ANTHROPIC_API_KEY" if has_openai: return "api-key", "OPENAI_API_KEY" + if has_opencode: + return "api-key", "OPENCODE_API_KEY" if has_authfile: return "auth-file", "" if has_vertex: return "vertex-ai", "" raise ValueError( - "opencode: no valid auth method found; set ANTHROPIC_API_KEY or " - f"OPENAI_API_KEY, provide auth credentials at {OPENCODE_AUTH_FILE}, " - "or configure GOOGLE_CLOUD_PROJECT + GOOGLE_CLOUD_REGION for Vertex AI" + "opencode: no valid auth method found; set ANTHROPIC_API_KEY, " + "OPENAI_API_KEY, or OPENCODE_API_KEY, provide auth credentials at " + f"{OPENCODE_AUTH_FILE}, or configure GOOGLE_CLOUD_PROJECT + " + "GOOGLE_CLOUD_REGION for Vertex AI" ) @@ -253,7 +297,10 @@ def _translate_mcp_server(name: str, spec: dict[str, Any]) -> dict[str, Any] | N if transport == "stdio": cmd = spec.get("command") if not isinstance(cmd, str) or not cmd: - print(f"opencode provision: mcp server {name!r}: stdio transport missing command", file=sys.stderr) + print( + f"opencode provision: mcp server {name!r}: stdio transport missing command", + file=sys.stderr, + ) return None args = spec.get("args") or [] if not isinstance(args, list): @@ -271,7 +318,10 @@ def _translate_mcp_server(name: str, spec: dict[str, Any]) -> dict[str, Any] | N if transport in ("sse", "streamable-http"): url = spec.get("url") if not isinstance(url, str) or not url: - print(f"opencode provision: mcp server {name!r}: {transport} transport missing url", file=sys.stderr) + print( + f"opencode provision: mcp server {name!r}: {transport} transport missing url", + file=sys.stderr, + ) return None out = { "type": "remote", @@ -282,7 +332,10 @@ def _translate_mcp_server(name: str, spec: dict[str, Any]) -> dict[str, Any] | N out["headers"] = {str(k): str(v) for k, v in headers.items()} return out - print(f"opencode provision: mcp server {name!r}: unsupported transport {transport!r}", file=sys.stderr) + print( + f"opencode provision: mcp server {name!r}: unsupported transport {transport!r}", + file=sys.stderr, + ) return None @@ -317,13 +370,16 @@ def _apply_mcp_servers(bundle: str) -> int: if not translated: return 0 - config_path = _expand(OPENCODE_CONFIG_FILE) + config_path = _resolve_opencode_config_path() config_data: dict[str, Any] = {} if os.path.isfile(config_path): try: - existing = _load_json(config_path) - except (OSError, json.JSONDecodeError) as exc: - print(f"opencode provision: existing opencode.json not readable, recreating: {exc}", file=sys.stderr) + existing = _load_json_or_jsonc(config_path) + except (OSError, json.JSONDecodeError, ValueError) as exc: + print( + f"opencode provision: existing {os.path.basename(config_path)} not readable, recreating: {exc}", + file=sys.stderr, + ) existing = {} if isinstance(existing, dict): config_data = existing @@ -338,10 +394,15 @@ def _apply_mcp_servers(bundle: str) -> int: try: _write_json(config_path, config_data) except OSError as exc: - print(f"opencode provision: failed to write opencode.json: {exc}", file=sys.stderr) + print( + f"opencode provision: failed to write {os.path.basename(config_path)}: {exc}", + file=sys.stderr, + ) return 0 - print(f"opencode provision: applied {len(translated)} mcp server(s)", file=sys.stderr) + print( + f"opencode provision: applied {len(translated)} mcp server(s)", file=sys.stderr + ) return len(translated) @@ -363,6 +424,25 @@ def _read_mcp_servers_inline(bundle: str) -> dict[str, dict[str, Any]]: return {str(k): v for k, v in servers.items() if isinstance(v, dict)} +def _inject_scion_plugin(bundle: str) -> None: + """Copy scion-plugin.js from harness bundle to OpenCode's plugin directory.""" + plugin_src = os.path.join(bundle, "scion-plugin.js") + if not os.path.isfile(plugin_src): + print( + "opencode provision: scion-plugin.js not found in harness bundle", + file=sys.stderr, + ) + return + plugin_dir = os.path.expanduser("~/.config/opencode/plugins") + os.makedirs(plugin_dir, exist_ok=True) + plugin_dst = os.path.join(plugin_dir, "scion-plugin.js") + with open(plugin_src, "r") as f: + content = f.read() + with open(plugin_dst, "w") as f: + f.write(content) + os.chmod(plugin_dst, 0o644) + + def _provision(manifest: dict[str, Any]) -> int: bundle = manifest.get("harness_bundle_dir") or "$HOME/.scion/harness" bundle = _expand(bundle) @@ -378,7 +458,10 @@ def _provision(manifest: dict[str, Any]) -> int: try: candidates = _load_json(auth_candidates_path) or {} except (OSError, json.JSONDecodeError) as exc: - print(f"opencode provision: invalid auth-candidates.json: {exc}", file=sys.stderr) + print( + f"opencode provision: invalid auth-candidates.json: {exc}", + file=sys.stderr, + ) return EXIT_ERROR explicit = str(candidates.get("explicit_type") or "").strip() @@ -394,19 +477,27 @@ def _provision(manifest: dict[str, Any]) -> int: secret_files = _env_secret_files(candidates) if not candidates and no_auth_behavior: - print(f"opencode provision: no-auth mode (behavior={no_auth_behavior}), skipping auth setup", file=sys.stderr) + print( + f"opencode 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, file_paths, candidates) + method, env_key = _select_auth_method( + explicit, env_keys, file_paths, candidates + ) 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")) + auth_out = _expand( + outputs.get("resolved_auth") + or os.path.join(bundle, "outputs", "resolved-auth.json") + ) resolved_payload: dict[str, Any] = { "schema_version": 1, @@ -427,9 +518,14 @@ def _provision(manifest: dict[str, Any]) -> int: env_payload: dict[str, Any] = {} if method == "vertex-ai": - project = _resolve_secret(secret_files, "GOOGLE_CLOUD_PROJECT", "VERTEXAI_PROJECT") + project = _resolve_secret( + secret_files, "GOOGLE_CLOUD_PROJECT", "VERTEXAI_PROJECT" + ) location = _resolve_secret( - secret_files, "GOOGLE_CLOUD_REGION", "GOOGLE_CLOUD_LOCATION", "VERTEX_LOCATION" + secret_files, + "GOOGLE_CLOUD_REGION", + "GOOGLE_CLOUD_LOCATION", + "VERTEX_LOCATION", ) if project: env_payload["VERTEXAI_PROJECT"] = project @@ -450,6 +546,8 @@ def _provision(manifest: dict[str, Any]) -> int: # transports are best-effort warn-and-skip). _apply_mcp_servers(bundle) + _inject_scion_plugin(bundle) + print(f"opencode provision: method={method}", file=sys.stderr) return EXIT_OK @@ -479,10 +577,16 @@ def main() -> int: try: manifest = _load_json(manifest_path) except FileNotFoundError: - print(f"opencode provision: manifest not found at {manifest_path}", file=sys.stderr) + print( + f"opencode provision: manifest not found at {manifest_path}", + file=sys.stderr, + ) return EXIT_ERROR except (OSError, json.JSONDecodeError) as exc: - print(f"opencode provision: failed to load manifest {manifest_path}: {exc}", file=sys.stderr) + print( + f"opencode provision: failed to load manifest {manifest_path}: {exc}", + file=sys.stderr, + ) return EXIT_ERROR if not isinstance(manifest, dict): diff --git a/harnesses/opencode/scion-plugin.js b/harnesses/opencode/scion-plugin.js new file mode 100644 index 000000000..f4f2d5674 --- /dev/null +++ b/harnesses/opencode/scion-plugin.js @@ -0,0 +1,482 @@ +// 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. + +/** + * OpenCode plugin that bridges OpenCode events to Scion's hook/status system. + * + * When running inside a Scion container (SCION_AGENT_ID is set), this plugin + * intercepts OpenCode plugin events and forwards them to `sciontool hook` so + * that the Scion Hub can track agent status in real-time. + * + * Event mapping: + * session.created -> session-start (activity: working) + * session.idle -> (no event) (activity: preserved) + * session.error -> session-end (activity: stopped) + * session.deleted -> session-end (activity: stopped) + * tool.execute.before -> tool-start (activity: executing) + * tool.execute.after -> tool-end (activity: working) + * message.updated (user) -> prompt-submit (activity: thinking) + * message.updated (assistant) -> model-start (activity: thinking) + * permission.asked -> notification (activity: waiting_for_input) + * tui.command.execute -> prompt-submit (activity: thinking) + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 200 // batch rapid events (tools) +const MSG_DEBOUNCE_MS = 2000 // longer debounce for streaming messages +const HEARTBEAT_INTERVAL_MS = 45_000 // 45s -- fires before 5min stalled threshold +const HOOK_TIMEOUT_MS = 5000 // max wait for sciontool hook to respond + +// Sticky activities that should not be overwritten by normal events +const STICKY_ACTIVITIES = new Set([ + "waiting_for_input", + "blocked", + "completed", + "limits_exceeded", + "crashed", +]) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Safely parse JSON string, returning null on failure. */ +function safeJSON(str) { + try { + return JSON.parse(str) + } catch { + return null + } +} + +/** Truncate a string to maxLen characters. */ +function truncate(str, maxLen) { + if (!str || str.length <= maxLen) return str + return str.slice(0, maxLen - 3) + "..." +} + +/** Escape a string for safe embedding in a shell single-quoted argument. */ +function shellEscape(str) { + if (!str) return "" + // Single-quote everything; internal single quotes become '\'' + return "'" + str.replace(/'/g, "'\\''") + "'" +} + +/** + * Send a normalized hook event to sciontool via stdin. + * + * Uses a temp file to pass JSON to sciontool, avoiding shell quoting issues + * with here-strings and pipes. The JSON payload matches the format expected + * by sciontool hook --dialect=opencode. + */ +async function sendHook(client, name, data = {}, logTag = "") { + const agentId = process.env.SCION_AGENT_ID + if (!agentId) return // Not in a Scion container + + const payload = JSON.stringify({ name, data }) + + try { + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: `scion hook: ${name}`, + extra: { name, data: JSON.stringify(data).slice(0, 500), tag: logTag }, + }, + }) + + // Write JSON to a temp file and pipe to sciontool -- avoids shell quoting + // issues with here-strings (<<<) that may not work in all environments. + const fs = await import("fs") + const tmpPath = `/tmp/scion-hook-${Date.now()}-${Math.random().toString(36).slice(2)}.json` + fs.writeFileSync(tmpPath, payload) + + // Fire-and-forget: don't await to avoid blocking the event handler. + // Log errors to the app logger for visibility (not just console.log). + const { execFile } = await import("child_process") + execFile( + "timeout", + [String(HOOK_TIMEOUT_MS), "sh", "-c", `cat $1 | sciontool hook --dialect=opencode 2>/dev/null; rm -f $1`, "_", tmpPath], + { timeout: HOOK_TIMEOUT_MS + 1000, stdio: "ignore" }, + (err) => { + if (err) { + client.app.log({ + body: { + service: "scion-plugin", + level: "error", + message: `scion hook failed: ${name}`, + extra: { error: String(err)?.message || String(err), name }, + }, + }).catch(() => {}) + } + }, + ) + } catch (err) { + await client.app.log({ + body: { + service: "scion-plugin", + level: "warn", + message: `scion hook send failed: ${name}`, + extra: { error: String(err) }, + }, + }) + } +} + +/** + * Send an activity update to sciontool hook. + * This is a convenience wrapper that sets the activity field in the event data. + */ +async function sendActivity(client, activity, extraData = {}) { + return sendHook(client, "_activity", { activity, ...extraData }) +} + +/** Check if the current activity is sticky (from local agent-info.json). */ +async function isSticky(client) { + try { + const info = safeJSON( + await client.$`cat ${process.env.HOME}/agent-info.json 2>/dev/null`.stdout + ) + if (info && info.activity) { + return STICKY_ACTIVITIES.has(info.activity) + } + } catch { + // Ignore -- file may not exist yet + } + return false +} + +/** + * Debounce wrapper: batches rapid calls into a single execution after a + * cooldown period. Returns a function that, when called, resets the timer. + */ +function debounce(fn, ms) { + let timer = null + return (...args) => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => fn(...args), ms) + } +} + +// --------------------------------------------------------------------------- +// Heartbeat +// --------------------------------------------------------------------------- + +/** + * Send a heartbeat to keep the agent from being marked as stalled. + * + * Scion's stalled detection triggers after 5 minutes of no activity events. + * We fire a lightweight heartbeat every 45 seconds to stay well under that + * threshold. Heartbeats are suppressed when the agent is in a sticky state + * (waiting_for_input, completed, etc.) because those are intentional pauses. + */ +async function startHeartbeat(client) { + let timer = null + + const tick = async () => { + const sticky = await isSticky(client) + if (sticky) { + // Don't heartbeat during sticky states -- the agent is intentionally paused + return + } + + // Send a single tool-end event to keep activity alive without the + // thinking->working flash that model-start/model-end pairs create. + await sendHook(client, "tool-end", { + tool_name: "heartbeat", + source: "opencode", + _scion_heartbeat: true, + }) + } + + timer = setInterval(tick, HEARTBEAT_INTERVAL_MS) + + // Store cleanup reference on the client for potential future use + client._scionHeartbeatTimer = timer + + // Fire one immediately in case the session has been running for a while + tick().catch(() => {}) +} + +/** Stop the heartbeat timer. */ +function stopHeartbeat(client) { + if (client._scionHeartbeatTimer) { + clearInterval(client._scionHeartbeatTimer) + client._scionHeartbeatTimer = null + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +export const ScionStatusPlugin = async ({ project, client, $, directory, worktree }) => { + const agentId = process.env.SCION_AGENT_ID + const hubEndpoint = process.env.SCION_HUB_ENDPOINT || process.env.SCION_HUB_URL || "" + + // Only activate inside Scion containers + if (!agentId) { + return {} + } + + // Log that the plugin is active + await client.app.log({ + body: { + service: "scion-plugin", + level: "info", + message: "Scion status plugin activated", + extra: { agentId, hubEndpoint, directory, worktree }, + }, + }) + + // Track assistant text from message.updated events for session-end forwarding + let assistantTextParts = [] + + // Debounced event senders + const debouncedHook = debounce(sendHook, DEBOUNCE_MS) + const debouncedMsgHook = debounce(sendHook, MSG_DEBOUNCE_MS) + + // Start heartbeat + await startHeartbeat(client) + + return { + // ----------------------------------------------------------------------- + // Session Lifecycle + // ----------------------------------------------------------------------- + + "session.created": async () => { + await sendHook(client, "session-start", { source: "opencode" }) + }, + + "session.deleted": async () => { + stopHeartbeat(client) + const assistantText = assistantTextParts.filter(Boolean).join("\n\n").slice(0, 65536) + await sendHook(client, "session-end", { + source: "opencode", + assistant_text: assistantText, + }) + }, + + "session.idle": async () => { + // Fire agent-end to create an explicit turn boundary, matching + // Claude's Stop -> agent-end -> working pattern. + await sendHook(client, "agent-end", { source: "opencode" }) + }, + + "session.error": async ({ error }) => { + stopHeartbeat(client) + const errorMsg = error ? String(error) : "Unknown error" + const assistantText = assistantTextParts.filter(Boolean).join("\n\n").slice(0, 65536) + await sendHook(client, "session-end", { + source: "opencode", + error: truncate(errorMsg, 200), + assistant_text: assistantText, + }) + }, + + // ----------------------------------------------------------------------- + // Tool Execution + // ----------------------------------------------------------------------- + + "tool.execute.before": async (input) => { + const toolName = input?.tool || "unknown" + await sendHook(client, "tool-start", { + tool_name: toolName, + source: "opencode", + }) + }, + + "tool.execute.after": async (input, output) => { + const toolName = input?.tool || "unknown" + const success = output?.success !== false + await sendHook(client, "tool-end", { + tool_name: toolName, + success, + source: "opencode", + }) + }, + + // ----------------------------------------------------------------------- + // Message Events + // ----------------------------------------------------------------------- + + "message.updated": async ({ event }) => { + if (!event) return + + const role = event?.role || "" + const content = event?.content || "" + const contentStr = typeof content === "string" ? content : JSON.stringify(content).slice(0, 200) + + if (role === "user") { + // User message -> prompt-submit (thinking) + // Only fire on the first user message to avoid spamming on edits + debouncedMsgHook(client, "prompt-submit", { + prompt: truncate(contentStr, 100), + source: "opencode", + }) + // Reset assistant text buffer on new user prompt + assistantTextParts = [] + } else if (role === "assistant") { + // Assistant message -> agent-start (thinking) to mark turn boundary, + // then model-start for the actual model response start + debouncedMsgHook(client, "agent-start", { source: "opencode" }) + debouncedMsgHook(client, "model-start", { + prompt: truncate(contentStr, 100), + source: "opencode", + }) + // Collect assistant text for session-end forwarding + assistantTextParts.push(contentStr) + } + }, + + // ----------------------------------------------------------------------- + // Permission Events + // ----------------------------------------------------------------------- + + "permission.asked": async (input) => { + // Permission prompt -> waiting_for_input (sticky) + const description = input?.description || input?.tool || "Permission required" + await sendHook(client, "notification", { + message: truncate(String(description), 100), + source: "opencode", + }) + }, + + "permission.replied": async (input) => { + // User replied to permission -- next tool-start will clear waiting_for_input + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "permission.replied", + extra: { decision: input?.decision }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // TUI Events + // ----------------------------------------------------------------------- + + "tui.command.execute": async (input) => { + const command = input?.command || input?.text || "" + await sendHook(client, "prompt-submit", { + prompt: truncate(String(command), 100), + source: "opencode-tui", + }) + }, + + "tui.toast.show": async (input) => { + // Toast notifications -- log but don't forward to sciontool + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "tui.toast.show", + extra: { text: String(input?.text || "").slice(0, 200) }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // File Events (for observability) + // ----------------------------------------------------------------------- + + "file.edited": async (input) => { + const filePath = input?.filePath || input?.path || "" + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "file.edited", + extra: { filePath: String(filePath).slice(0, 200) }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // Shell Events + // ----------------------------------------------------------------------- + + "shell.env": async (input, output) => { + // Shell environment hook -- could inject Scion-specific env vars + // for now, just log + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "shell.env", + extra: { cwd: input?.cwd }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // Command Events + // ----------------------------------------------------------------------- + + "command.executed": async (input) => { + const commandName = input?.command || input?.name || "" + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "command.executed", + extra: { command: String(commandName).slice(0, 200) }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // LSP Events (observability only) + // ----------------------------------------------------------------------- + + "lsp.client.diagnostics": async (input) => { + const diagnostics = input?.diagnostics || [] + const errorCount = Array.isArray(diagnostics) + ? diagnostics.filter((d) => d?.severity === 1).length + : 0 + await client.app.log({ + body: { + service: "scion-plugin", + level: errorCount > 0 ? "warn" : "debug", + message: "lsp.diagnostics", + extra: { total: diagnostics.length, errors: errorCount }, + }, + }) + }, + + // ----------------------------------------------------------------------- + // Todo Events + // ----------------------------------------------------------------------- + + "todo.updated": async (input) => { + const todo = input?.todo || {} + await client.app.log({ + body: { + service: "scion-plugin", + level: "debug", + message: "todo.updated", + extra: { + title: String(todo?.title || "").slice(0, 100), + status: todo?.status, + }, + }, + }) + }, + } +} diff --git a/image-build/scripts/build-images.sh b/image-build/scripts/build-images.sh index 66d50d981..9b24d8110 100755 --- a/image-build/scripts/build-images.sh +++ b/image-build/scripts/build-images.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/image-build/scripts/builders/local-docker.sh b/image-build/scripts/builders/local-docker.sh index bdc6c9e98..2385e1661 100755 --- a/image-build/scripts/builders/local-docker.sh +++ b/image-build/scripts/builders/local-docker.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/image-build/scripts/lib/targets.sh b/image-build/scripts/lib/targets.sh index 4ab3e6de7..11144fe51 100644 --- a/image-build/scripts/lib/targets.sh +++ b/image-build/scripts/lib/targets.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/internal/testgit/testgit.go b/internal/testgit/testgit.go new file mode 100644 index 000000000..48f5358e8 --- /dev/null +++ b/internal/testgit/testgit.go @@ -0,0 +1,59 @@ +// 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 testgit provides helpers for running git commands in tests without +// triggering interactive prompts or macOS keychain access. +package testgit + +import ( + "os" + "os/exec" +) + +// Env returns a copy of the current process environment with git credentials +// and interactive prompts disabled. This prevents macOS keychain popups and +// git credential helper prompts during test execution. +func Env() []string { + env := os.Environ() + env = append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=", + "SSH_ASKPASS=", + "SSH_ASKPASS_REQUIRE=never", + ) + return env +} + +// Setup sets environment variables in the current process to prevent git from +// triggering macOS keychain prompts or interactive credential helpers during +// tests. Call this from TestMain before running tests in packages that execute +// git commands. +func Setup() { + os.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + os.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") + os.Setenv("GIT_TERMINAL_PROMPT", "0") + os.Setenv("GIT_ASKPASS", "") + os.Setenv("SSH_ASKPASS", "") + os.Setenv("SSH_ASKPASS_REQUIRE", "never") +} + +// Command creates an exec.Cmd for a git command with environment variables set +// to prevent keychain access and interactive prompts. +func Command(args ...string) *exec.Cmd { + cmd := exec.Command("git", args...) + cmd.Env = Env() + return cmd +} diff --git a/pkg/agent/delete_test.go b/pkg/agent/delete_test.go index fa785b54d..5b93adac0 100644 --- a/pkg/agent/delete_test.go +++ b/pkg/agent/delete_test.go @@ -82,7 +82,7 @@ func TestDeleteAgentFiles_CleansStaleWorktree(t *testing.T) { os.MkdirAll(agentDir, 0755) // Create a worktree at the workspace path (simulates a successful start) - if err := util.CreateWorktree(agentWorkspace, agentName); err != nil { + if err := util.CreateWorktree(agentWorkspace, agentName, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } @@ -211,7 +211,7 @@ func TestDeleteAgentFiles_CleansWorktreeWithGitFile(t *testing.T) { os.MkdirAll(agentDir, 0755) // Create a proper worktree (has .git file) - if err := util.CreateWorktree(agentWorkspace, agentName); err != nil { + if err := util.CreateWorktree(agentWorkspace, agentName, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } diff --git a/pkg/agent/main_test.go b/pkg/agent/main_test.go new file mode 100644 index 000000000..d63ef00c5 --- /dev/null +++ b/pkg/agent/main_test.go @@ -0,0 +1,13 @@ +package agent + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/agent/provision.go b/pkg/agent/provision.go index 7878d0a89..02943c679 100644 --- a/pkg/agent/provision.go +++ b/pkg/agent/provision.go @@ -355,7 +355,7 @@ func (m *AgentManager) Provision(ctx context.Context, opts api.StartOptions) (*a } inlineCfg.AuthSelectedType = opts.HarnessAuth } - agentDir, _, _, cfg, err := GetAgent(ctx, opts.Name, opts.Template, opts.Image, opts.HarnessConfig, opts.ProjectPath, opts.Profile, "created", opts.Branch, opts.Workspace, inlineCfg) + agentDir, _, _, cfg, err := GetAgent(ctx, opts.Name, opts.Template, opts.Image, opts.HarnessConfig, opts.ProjectPath, opts.Profile, "created", opts.Branch, opts.Source, opts.Workspace, inlineCfg) if err == nil { _ = UpdateAgentConfig(opts.Name, opts.ProjectPath, "created", m.Runtime.Name(), opts.Profile) } @@ -396,7 +396,7 @@ func resolveHarnessConfigDir(ctx context.Context, name, projectPath string, temp return config.FindHarnessConfigDir(name, projectPath, templatePaths...) } -func ProvisionAgent(ctx context.Context, agentName string, templateName string, agentImage string, harnessConfig string, projectPath string, profileName string, optionalStatus string, branch string, workspace string, inlineConfig ...*api.ScionConfig) (string, string, *api.ScionConfig, error) { +func ProvisionAgent(ctx context.Context, agentName string, templateName string, agentImage string, harnessConfig string, projectPath string, profileName string, optionalStatus string, branch string, source string, workspace string, inlineConfig ...*api.ScionConfig) (string, string, *api.ScionConfig, error) { provisionStart := time.Now() // 1. Prepare agent directories projectDir, err := config.GetResolvedProjectDir(projectPath) @@ -588,7 +588,11 @@ func ProvisionAgent(ctx context.Context, agentName string, templateName string, worktreeBranch = api.Slugify(agentName) } - if err := util.CreateWorktree(agentWorkspace, worktreeBranch); err != nil { + sourceBranch := source + if sourceBranch == "" { + sourceBranch = util.DefaultBranch(projectDir) + } + if err := util.CreateWorktree(agentWorkspace, worktreeBranch, sourceBranch); err != nil { return "", "", nil, fmt.Errorf("failed to create git worktree: %w", err) } util.Debugf("provision: worktree created in %s", time.Since(worktreeStart)) @@ -1176,7 +1180,8 @@ func ProvisionAgent(ctx context.Context, agentName string, templateName string, // into the harness bundle so they are available at a known path in the // container. Container-script harnesses stage these during their own // Provision(); this path handles non-container-script fallbacks. -if _, isContainerScript := h.(*harness.ContainerScriptHarness); !isContainerScript && hcDir != nil { + if _, isContainerScript := h.(*harness.ContainerScriptHarness); !isContainerScript && hcDir != nil { + if err := harness.StageCaptureAuthAssets(agentHome, hcDir.Path, hcDir.Config.Auth); err != nil { fmt.Fprintf(os.Stderr, "Warning: capture-auth asset staging failed: %v\n", err) } } @@ -1484,7 +1489,7 @@ func UpdateAgentDeletedAt(agentName string, projectPath string, deletedAt time.T return os.WriteFile(agentInfoPath, newData, 0644) } -func GetAgent(ctx context.Context, agentName string, templateName string, agentImage string, harnessConfig string, projectPath string, profileName string, optionalStatus string, branch string, workspace string, inlineConfig ...*api.ScionConfig) (string, string, string, *api.ScionConfig, error) { +func GetAgent(ctx context.Context, agentName string, templateName string, agentImage string, harnessConfig string, projectPath string, profileName string, optionalStatus string, branch string, source string, workspace string, inlineConfig ...*api.ScionConfig) (string, string, string, *api.ScionConfig, error) { projectDir, err := config.GetResolvedProjectDir(projectPath) if err != nil { return "", "", "", nil, err @@ -1540,7 +1545,11 @@ func GetAgent(ctx context.Context, agentName string, templateName string, agentI if root, rootErr := util.RepoRootDir(filepath.Dir(agentWorkspace)); rootErr == nil { _ = util.PruneWorktreesIn(root) } - if err := util.CreateWorktree(agentWorkspace, targetBranch); err != nil { + sourceBranch := source + if sourceBranch == "" { + sourceBranch = util.DefaultBranch(projectDir) + } + if err := util.CreateWorktree(agentWorkspace, targetBranch, sourceBranch); err != nil { util.Debugf("GetAgent: failed to recreate worktree at %s: %v, clearing workspace", agentWorkspace, err) agentWorkspace = "" } else { @@ -1572,7 +1581,7 @@ func GetAgent(ctx context.Context, agentName string, templateName string, agentI if len(inlineConfig) > 0 { ic = inlineConfig[0] } - home, ws, cfg, err := ProvisionAgent(ctx, agentName, templateName, agentImage, harnessConfig, projectPath, profileName, optionalStatus, branch, workspace, ic) + home, ws, cfg, err := ProvisionAgent(ctx, agentName, templateName, agentImage, harnessConfig, projectPath, profileName, optionalStatus, branch, source, workspace, ic) if err != nil { util.Debugf("GetAgent: ProvisionAgent failed: %v", err) } else { diff --git a/pkg/agent/provision_compose_test.go b/pkg/agent/provision_compose_test.go index 61a747f19..37305f52c 100644 --- a/pkg/agent/provision_compose_test.go +++ b/pkg/agent/provision_compose_test.go @@ -70,7 +70,7 @@ func TestComposition_HarnessConfigBaseLayer(t *testing.T) { os.MkdirAll(tplDir, 0755) os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte("default_harness_config: test-hc\n"), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "base-agent", "base-test", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "base-agent", "base-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -112,7 +112,7 @@ func TestComposition_TemplateOverlay(t *testing.T) { os.WriteFile(filepath.Join(tplHome, "shared-file.txt"), []byte("from-template"), 0644) // overlay os.WriteFile(filepath.Join(tplHome, "template-only.txt"), []byte("template-only-content"), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "overlay-agent", "overlay-test", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "overlay-agent", "overlay-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -158,7 +158,7 @@ agent_instructions: "You are a helpful coding assistant." ` os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte(tplConfig), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "inline-agent", "inline-instructions", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "inline-agent", "inline-instructions", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -181,7 +181,7 @@ agent_instructions: my-instructions.md ` os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte(tplConfig), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "file-instr-agent", "file-instructions", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "file-instr-agent", "file-instructions", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -208,7 +208,7 @@ system_prompt: "Be concise and precise." ` os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte(tplConfig), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "sysprompt-agent", "sysprompt-test", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "sysprompt-agent", "sysprompt-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -232,7 +232,7 @@ func TestComposition_CommonFiles(t *testing.T) { os.MkdirAll(tplDir, 0755) os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte("default_harness_config: common-hc\n"), 0644) - agentHome, _, _, err := ProvisionAgent(context.Background(), "common-agent", "common-test", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "common-agent", "common-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -266,7 +266,7 @@ func TestComposition_HarnessConfigResolution(t *testing.T) { os.WriteFile(filepath.Join(tplDirNoDefault, "scion-agent.yaml"), []byte("env:\n FOO: bar\n"), 0644) t.Run("CLI flag wins over template", func(t *testing.T) { - _, _, cfg, err := ProvisionAgent(context.Background(), "cli-wins", "resolve-test", "", "cli-hc", projectScionDir, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), "cli-wins", "resolve-test", "", "cli-hc", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -276,7 +276,7 @@ func TestComposition_HarnessConfigResolution(t *testing.T) { }) t.Run("template default used when no CLI flag", func(t *testing.T) { - _, _, cfg, err := ProvisionAgent(context.Background(), "tpl-default", "resolve-test", "", "", projectScionDir, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), "tpl-default", "resolve-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -295,7 +295,7 @@ profiles: ` os.WriteFile(filepath.Join(globalScionDir, "settings.yaml"), []byte(settingsYAML), 0644) - _, _, cfg, err := ProvisionAgent(context.Background(), "profile-default", "no-default-test", "", "", projectScionDir, "test-profile", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), "profile-default", "no-default-test", "", "", projectScionDir, "test-profile", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -312,7 +312,7 @@ default_harness_config: settings-hc ` os.WriteFile(filepath.Join(globalScionDir, "settings.yaml"), []byte(settingsYAML), 0644) - _, _, cfg, err := ProvisionAgent(context.Background(), "settings-default", "no-default-test", "", "", projectScionDir, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), "settings-default", "no-default-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -323,7 +323,7 @@ default_harness_config: settings-hc }) t.Run("error when no harness-config resolved", func(t *testing.T) { - _, _, _, err := ProvisionAgent(context.Background(), "no-hc", "no-default-test", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), "no-hc", "no-default-test", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error when no harness-config can be resolved") } @@ -341,7 +341,7 @@ func TestComposition_LegacyTemplateRejected(t *testing.T) { os.MkdirAll(tplDir, 0755) os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte("harness: claude\n"), 0644) - _, _, _, err := ProvisionAgent(context.Background(), "legacy-agent", "legacy-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), "legacy-agent", "legacy-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error for legacy template with 'harness' field") } @@ -359,7 +359,7 @@ func TestComposition_HarnessConfigPersistedInAgentInfo(t *testing.T) { os.MkdirAll(tplDir, 0755) os.WriteFile(filepath.Join(tplDir, "scion-agent.yaml"), []byte("default_harness_config: persist-hc\n"), 0644) - agentHome, _, cfg, err := ProvisionAgent(context.Background(), "persist-agent", "persist-test", "", "", projectScionDir, "", "", "", "") + agentHome, _, cfg, err := ProvisionAgent(context.Background(), "persist-agent", "persist-test", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -461,7 +461,7 @@ func TestComposition_InlineHarnessConfigWithAgentInstructions(t *testing.T) { _, globalScionDir, projectScionDir := setupCompositionTest(t) setupInlineHarnessTemplate(t, globalScionDir, "web-dev-explicit", "agents.md") - agentHome, _, _, err := ProvisionAgent(context.Background(), "explicit-instruct", "web-dev-explicit", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "explicit-instruct", "web-dev-explicit", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -477,7 +477,7 @@ func TestComposition_InlineHarnessConfigAutoDetectsAgentsMd(t *testing.T) { _, globalScionDir, projectScionDir := setupCompositionTest(t) setupInlineHarnessTemplate(t, globalScionDir, "web-dev-auto", "") // no agent_instructions - agentHome, _, _, err := ProvisionAgent(context.Background(), "auto-instruct", "web-dev-auto", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), "auto-instruct", "web-dev-auto", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -512,7 +512,7 @@ func TestComposition_FullInitProjectFlow(t *testing.T) { os.Chdir(projectDir) // Use the "default" template (agnostic); default_harness_config: claude comes from settings - agentHome, _, cfg, err := ProvisionAgent(context.Background(), "full-flow-agent", "default", "", "", projectScionDir, "", "", "", "") + agentHome, _, cfg, err := ProvisionAgent(context.Background(), "full-flow-agent", "default", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } diff --git a/pkg/agent/provision_home_test.go b/pkg/agent/provision_home_test.go index b40f190bf..89daa9d04 100644 --- a/pkg/agent/provision_home_test.go +++ b/pkg/agent/provision_home_test.go @@ -79,7 +79,7 @@ func TestProvisionAgentHomeCopy(t *testing.T) { // Provision agent agentName := "test-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "test-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "test-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -135,7 +135,7 @@ func TestProvisionAgentLegacyTemplateRejected(t *testing.T) { // Provision agent - should fail with validation error agentName := "legacy-agent" - _, _, _, err := ProvisionAgent(context.Background(), agentName, "legacy-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), agentName, "legacy-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error for legacy template with harness field, got nil") } diff --git a/pkg/agent/provision_reload_test.go b/pkg/agent/provision_reload_test.go index d15e48309..1ce2b4a93 100644 --- a/pkg/agent/provision_reload_test.go +++ b/pkg/agent/provision_reload_test.go @@ -60,7 +60,7 @@ func TestProvisionAgentReloadsConfig(t *testing.T) { // Provision a claude agent using the "default" agnostic template with --harness-config=claude agentName := "reload-test-agent" - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -106,7 +106,7 @@ func TestProvisionAgentWithHarnessAuthOverride(t *testing.T) { // Provision with vertex-ai override via inline config (simulates --harness-auth vertex-ai) agentName := "vertex-ai-override" inlineCfg := &api.ScionConfig{AuthSelectedType: "vertex-ai"} - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "", inlineCfg) + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "", "", inlineCfg) if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } diff --git a/pkg/agent/provision_test.go b/pkg/agent/provision_test.go index bf347305f..9e607c093 100644 --- a/pkg/agent/provision_test.go +++ b/pkg/agent/provision_test.go @@ -103,7 +103,7 @@ profiles: // Provision agent agentName := "test-agent" - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "test-tpl", "", "", projectScionDir, "test-profile", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "test-tpl", "", "", projectScionDir, "test-profile", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -177,7 +177,7 @@ func TestProvisionGeminiAgentSettings(t *testing.T) { // Provision a claude agent using the "default" agnostic template agentName := "gemini-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "default", "", "claude", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -315,7 +315,7 @@ func TestProvisionAgentNonGitWorkspace(t *testing.T) { evalProjectDir, _ := filepath.EvalSymlinks(projectDir) agentName := "test-agent" - home, ws, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "", projectScionDir, "", "", "", "") + home, ws, cfg, err := ProvisionAgent(context.Background(), agentName, "default", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -357,7 +357,7 @@ func TestProvisionAgentNonGitWorkspace(t *testing.T) { } evalCWD, _ := filepath.EvalSymlinks(cwd) - _, ws, cfg, err = ProvisionAgent(context.Background(), "global-agent", "default", "", "", globalScionDir, "", "", "", "") + _, ws, cfg, err = ProvisionAgent(context.Background(), "global-agent", "default", "", "", globalScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed for global project: %v", err) } @@ -420,7 +420,7 @@ func TestProvisionAgentWorkspaceFlag(t *testing.T) { // 1. Test valid --workspace in non-git agentName := "workspace-agent" - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "claude", "", "", projectScionDir, "", "", "", customWorkspace) + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "claude", "", "", projectScionDir, "", "", "", "", customWorkspace) if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -448,7 +448,7 @@ func TestProvisionAgentWorkspaceFlag(t *testing.T) { absRelativeWorkspace, _ := filepath.Abs(filepath.Join(tmpDir, relativeWorkspace)) evalAbsRelativeWorkspace, _ := filepath.EvalSymlinks(absRelativeWorkspace) - _, _, cfg, err = ProvisionAgent(context.Background(), "rel-agent", "claude", "", "", projectScionDir, "", "", "", relativeWorkspace) + _, _, cfg, err = ProvisionAgent(context.Background(), "rel-agent", "claude", "", "", projectScionDir, "", "", "", "", relativeWorkspace) if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -475,7 +475,7 @@ func TestProvisionAgentWorkspaceFlag(t *testing.T) { os.WriteFile(filepath.Join(gitDir, ".gitignore"), []byte("agents/"), 0644) var ws string - _, ws, cfg, err = ProvisionAgent(context.Background(), "git-agent", "claude", "", "", gitScionDir, "", "", "", customWorkspace) + _, ws, cfg, err = ProvisionAgent(context.Background(), "git-agent", "claude", "", "", gitScionDir, "", "", "", "", customWorkspace) if err != nil { t.Fatalf("expected no error when using --workspace in a git repository, got: %v", err) } @@ -537,7 +537,7 @@ auth_selectedType: vertex-ai // Provision agent agentName := "yaml-agent" - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "yaml-test-tpl", "", "", projectScionDir, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "yaml-test-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -616,7 +616,7 @@ func TestProvisionAgentUsesProjectTemplate(t *testing.T) { // Provision agent using projectPath — the project template should be used // even though CWD has no .scion directory. agentName := "project-tpl-agent" - _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "my-tpl", "", "", projectPath, "", "", "", "") + _, _, cfg, err := ProvisionAgent(context.Background(), agentName, "my-tpl", "", "", projectPath, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -664,7 +664,7 @@ env: // Provision agent - should fail with an error agentName := "invalid-yaml-agent" - _, _, _, err := ProvisionAgent(context.Background(), agentName, "invalid-yaml-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), agentName, "invalid-yaml-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error for invalid YAML template, got nil") } @@ -715,7 +715,7 @@ services: os.MkdirAll(projectScionDir, 0755) agentName := "svc-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "svc-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "svc-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -746,7 +746,7 @@ services: os.MkdirAll(projectScionDir, 0755) agentName := "no-svc-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "no-svc-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "no-svc-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -793,7 +793,7 @@ func TestProvisionAgent_CopiesSkillsDir(t *testing.T) { os.MkdirAll(projectScionDir, 0755) agentName := "skills-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "skills-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "skills-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -849,7 +849,7 @@ func TestProvisionAgent_SkillsAreTemplateOnly(t *testing.T) { os.MkdirAll(projectScionDir, 0755) agentName := "overlay-agent" - agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "overlay-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(context.Background(), agentName, "overlay-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -909,7 +909,7 @@ func TestProvisionAgentGitClone_ClearsStaleWorktreeWorkspace(t *testing.T) { } ctx := api.ContextWithGitClone(context.Background(), gitClone) - _, wsPath, _, err := ProvisionAgent(ctx, "clone-agent", "claude", "", "", projectScionDir, "", "", "", "") + _, wsPath, _, err := ProvisionAgent(ctx, "clone-agent", "claude", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -970,7 +970,7 @@ func TestProvisionAgentGitClone_PreservesExistingClone(t *testing.T) { } ctx := api.ContextWithGitClone(context.Background(), gitClone) - _, wsPath, _, err := ProvisionAgent(ctx, "restart-agent", "claude", "", "", projectScionDir, "", "", "", "") + _, wsPath, _, err := ProvisionAgent(ctx, "restart-agent", "claude", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -1034,7 +1034,7 @@ func TestGetAgentGitClone_ClearsExistingWorkspace(t *testing.T) { } ctx := api.ContextWithGitClone(context.Background(), gitClone) - _, _, wsPath, _, err := GetAgent(ctx, "reused-agent", "claude", "", "", projectScionDir, "", "", "", "") + _, _, wsPath, _, err := GetAgent(ctx, "reused-agent", "claude", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("GetAgent failed: %v", err) } @@ -1233,7 +1233,7 @@ func TestProvisionAgent_SharedWorkspaceCredentialHelper(t *testing.T) { // Set SharedWorkspace context ctx := api.ContextWithSharedWorkspace(context.Background()) - home, _, _, err := ProvisionAgent(ctx, "shared-agent", "claude", "", "", projectScionDir, "", "", "", sharedWorkspace) + home, _, _, err := ProvisionAgent(ctx, "shared-agent", "claude", "", "", projectScionDir, "", "", "", "", sharedWorkspace) if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -1286,7 +1286,7 @@ func TestProvisionAgent_SharedWorkspaceNoCredentialWithoutFlag(t *testing.T) { os.MkdirAll(customWorkspace, 0755) // No SharedWorkspace context — plain workspace mount - home, _, _, err := ProvisionAgent(context.Background(), "plain-agent", "claude", "", "", projectScionDir, "", "", "", customWorkspace) + home, _, _, err := ProvisionAgent(context.Background(), "plain-agent", "claude", "", "", projectScionDir, "", "", "", "", customWorkspace) if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -1356,7 +1356,7 @@ func TestGetAgent_RecreatesMissingWorktree(t *testing.T) { os.MkdirAll(agentHome, 0755) // Create a worktree (simulating a successful first provision) - if err := util.CreateWorktree(agentWorkspace, agentName); err != nil { + if err := util.CreateWorktree(agentWorkspace, agentName, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } @@ -1386,7 +1386,7 @@ func TestGetAgent_RecreatesMissingWorktree(t *testing.T) { } // Call GetAgent — it should recreate the worktree - _, _, wsPath, _, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "") + _, _, wsPath, _, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "", "") if err != nil { t.Fatalf("GetAgent failed: %v", err) } @@ -1465,7 +1465,7 @@ func TestGetAgent_StaleDirectoryCreatesWorkspace(t *testing.T) { // Call GetAgent — it should detect the stale directory, remove it, // and re-provision successfully with a workspace worktree. - _, _, wsPath, cfg, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "") + _, _, wsPath, cfg, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "", "") if err != nil { t.Fatalf("GetAgent failed: %v", err) } @@ -1544,7 +1544,7 @@ func TestGetAgent_BrandNewAgentCreatesWorkspace(t *testing.T) { } // Call GetAgent — it should provision from scratch with a workspace worktree. - _, _, wsPath, cfg, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "") + _, _, wsPath, cfg, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "", "") if err != nil { t.Fatalf("GetAgent failed: %v", err) } @@ -1603,7 +1603,7 @@ func TestGetAgent_MissingWorkspaceNonGit(t *testing.T) { os.WriteFile(filepath.Join(agentHome, "agent-info.json"), []byte(`{"name":"nongit-agent","template":"default"}`), 0644) - _, _, wsPath, _, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "") + _, _, wsPath, _, err := GetAgent(context.Background(), agentName, "", "", "", scionDir, "", "", "", "", "") if err != nil { t.Fatalf("GetAgent failed: %v", err) } @@ -1666,7 +1666,7 @@ func TestProvisionAgent_SkillsWithMockResolver(t *testing.T) { _ = contentHash ctx := ContextWithSkillResolver(context.Background(), resolver) - agentHome, _, _, err := ProvisionAgent(ctx, "skill-ref-agent", "skill-ref-tpl", "", "", projectScionDir, "", "", "", "") + agentHome, _, _, err := ProvisionAgent(ctx, "skill-ref-agent", "skill-ref-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("ProvisionAgent failed: %v", err) } @@ -1717,7 +1717,7 @@ func TestProvisionAgent_RequiredSkillsNoResolver(t *testing.T) { os.MkdirAll(projectScionDir, 0755) // No resolver on context → should fail for required skills - _, _, _, err := ProvisionAgent(context.Background(), "no-resolver-agent", "required-skill-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), "no-resolver-agent", "required-skill-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected provisioning to fail with required skills and no resolver") } @@ -1760,7 +1760,7 @@ func TestProvisionAgent_OptionalSkillsNoResolver(t *testing.T) { os.MkdirAll(projectScionDir, 0755) // No resolver on context → should succeed for optional-only skills - _, _, _, err := ProvisionAgent(context.Background(), "optional-agent", "optional-skill-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), "optional-agent", "optional-skill-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("expected provisioning to succeed with optional-only skills and no resolver, got: %v", err) } @@ -1799,7 +1799,7 @@ skills: os.MkdirAll(projectScionDir, 0755) // This should fail because there's no resolver for the required skill - _, _, _, err := ProvisionAgent(context.Background(), "yaml-skills-agent", "yaml-skills-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(context.Background(), "yaml-skills-agent", "yaml-skills-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error for required skill with no resolver") } @@ -1848,7 +1848,7 @@ func TestProvisionAgent_SkillsResolverError(t *testing.T) { }, } ctx := ContextWithSkillResolver(context.Background(), resolver) - _, _, _, err := ProvisionAgent(ctx, "resolver-err-agent", "resolver-err-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(ctx, "resolver-err-agent", "resolver-err-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error for required skill resolution failure") } @@ -1895,7 +1895,7 @@ func TestProvisionAgent_RequiredSkillOmittedFromResolverResponse(t *testing.T) { }, } ctx := ContextWithSkillResolver(context.Background(), resolver) - _, _, _, err := ProvisionAgent(ctx, "omitted-agent", "omitted-required-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(ctx, "omitted-agent", "omitted-required-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error when required skill is missing from resolver response") } @@ -1945,7 +1945,7 @@ func TestProvisionAgent_OptionalSkillOmittedFromResolverResponse(t *testing.T) { }, } ctx := ContextWithSkillResolver(context.Background(), resolver) - _, _, _, err := ProvisionAgent(ctx, "omitted-opt-agent", "omitted-optional-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(ctx, "omitted-opt-agent", "omitted-optional-tpl", "", "", projectScionDir, "", "", "", "", "") if err != nil { t.Fatalf("expected provisioning to succeed when only optional skill is omitted, got: %v", err) } @@ -1989,7 +1989,7 @@ func TestProvisionAgent_UnrequestedSkillFromResolver(t *testing.T) { }, } ctx := ContextWithSkillResolver(context.Background(), resolver) - _, _, _, err := ProvisionAgent(ctx, "extra-skill-agent", "extra-skill-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(ctx, "extra-skill-agent", "extra-skill-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error when resolver returns unrequested skill") } @@ -2039,7 +2039,7 @@ func TestProvisionAgent_DuplicateResolvedSkill(t *testing.T) { }, } ctx := ContextWithSkillResolver(context.Background(), resolver) - _, _, _, err := ProvisionAgent(ctx, "dup-skill-agent", "dup-skill-tpl", "", "", projectScionDir, "", "", "", "") + _, _, _, err := ProvisionAgent(ctx, "dup-skill-agent", "dup-skill-tpl", "", "", projectScionDir, "", "", "", "", "") if err == nil { t.Fatal("expected error when resolver returns duplicate skill") } diff --git a/pkg/agent/run.go b/pkg/agent/run.go index ca6e08d3a..7943477a0 100644 --- a/pkg/agent/run.go +++ b/pkg/agent/run.go @@ -142,7 +142,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A util.Debugf("Start: calling GetAgent name=%s template=%q image=%q harnessConfig=%q projectPath=%q profile=%q", opts.Name, opts.Template, opts.Image, opts.HarnessConfig, opts.ProjectPath, opts.Profile) - agentDir, agentHome, agentWorkspace, finalScionCfg, err := GetAgent(ctx, opts.Name, opts.Template, opts.Image, opts.HarnessConfig, opts.ProjectPath, opts.Profile, "", opts.Branch, opts.Workspace, startInlineConfig) + agentDir, agentHome, agentWorkspace, finalScionCfg, err := GetAgent(ctx, opts.Name, opts.Template, opts.Image, opts.HarnessConfig, opts.ProjectPath, opts.Profile, "", opts.Branch, opts.Source, opts.Workspace, startInlineConfig) if err != nil { return nil, err } @@ -179,6 +179,10 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A } config.PrintDeprecationWarnings(settingsWarnings) + if settings != nil && settings.DisableLocalAuth != nil && *settings.DisableLocalAuth { + opts.DisableLocalAuth = true + } + // Phase 5: Resolve project ID from settings if not already provided via env if projectID == "" && settings != nil && settings.Hub != nil { projectID = settings.Hub.ProjectID @@ -367,6 +371,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A ProfileName: profileName, Settings: settings, ConfigDirPath: opts.HarnessConfigPath, + HarnessAuth: opts.HarnessAuth, }) if err != nil { util.Debugf("harness.Resolve fell back to New(%q): %v", harnessName, err) @@ -407,7 +412,8 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A // auth overlay so that GatherAuthWithEnv can see credentials like // GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_REGION declared in the active // settings profile. - if settings != nil && !opts.BrokerMode { + localSources := !opts.BrokerMode && !opts.DisableLocalAuth + if settings != nil && localSources { var settingsEnv map[string]string if harnessConfigName != "" { if hcEntry, err := settings.ResolveHarnessConfig(profileName, harnessConfigName); err == nil { @@ -442,7 +448,7 @@ func (m *AgentManager) Start(ctx context.Context, opts api.StartOptions) (*api.A var auth api.AuthConfig var resolvedAuth *api.ResolvedAuth if !opts.NoAuth { - auth = harness.GatherAuthWithEnv(authEnvOverlay, !opts.BrokerMode, authMeta) + auth = harness.GatherAuthWithEnv(authEnvOverlay, localSources, authMeta) if opts.BrokerMode { harness.OverlayFileSecrets(&auth, opts.ResolvedSecrets) } @@ -1225,6 +1231,7 @@ func isAuthEnvKey(key string, extraAuthKeys ...map[string]struct{}) bool { "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", + "OPENCODE_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCP_PROJECT", "ANTHROPIC_VERTEX_PROJECT_ID", diff --git a/pkg/agent/run_test.go b/pkg/agent/run_test.go index 51b2cc5f0..b907383d8 100644 --- a/pkg/agent/run_test.go +++ b/pkg/agent/run_test.go @@ -1796,6 +1796,7 @@ func TestIsAuthEnvKey_BuiltinKeys(t *testing.T) { builtins := []string{ "GEMINI_API_KEY", "GOOGLE_API_KEY", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY", + "OPENCODE_API_KEY", "GOOGLE_CLOUD_PROJECT", "GCP_PROJECT", "ANTHROPIC_VERTEX_PROJECT_ID", "GOOGLE_CLOUD_REGION", "CLOUD_ML_REGION", "GOOGLE_CLOUD_LOCATION", } diff --git a/pkg/api/harness_capabilities.go b/pkg/api/harness_capabilities.go index aacb6bfc9..cf638a8f3 100644 --- a/pkg/api/harness_capabilities.go +++ b/pkg/api/harness_capabilities.go @@ -54,6 +54,7 @@ type HarnessAuthCapabilities struct { AuthFile CapabilityField `json:"auth_file" yaml:"auth_file"` OAuthToken CapabilityField `json:"oauth_token" yaml:"oauth_token"` VertexAI CapabilityField `json:"vertex_ai" yaml:"vertex_ai"` + None CapabilityField `json:"none" yaml:"none"` } // HarnessMCPCapabilities describes MCP transport support for a harness. diff --git a/pkg/api/types.go b/pkg/api/types.go index f936b998c..97497a18b 100644 --- a/pkg/api/types.go +++ b/pkg/api/types.go @@ -475,6 +475,7 @@ type ScionConfig struct { // Agent operational parameters (creation-time record) Task string `json:"task,omitempty" yaml:"task,omitempty"` Branch string `json:"branch,omitempty" yaml:"branch,omitempty"` + Source string `json:"source,omitempty" yaml:"source,omitempty"` // Info contains persisted metadata about the agent Info *AgentInfo `json:"-" yaml:"-"` @@ -506,10 +507,11 @@ type AuthConfig struct { ClaudeOAuthToken string // CLAUDE_CODE_OAUTH_TOKEN (long-lived, from `claude setup-token`) ClaudeAuthFile string // ~/.claude/.credentials.json path (rotating refresh-token store) - // OpenAI/Codex auth + // OpenAI/Codex/OpenCode auth OpenAIAPIKey string CodexAPIKey string CodexAuthFile string + OpenCodeAPIKey string OpenCodeAuthFile string // GCP metadata server mode ("block", "passthrough", "assign"). @@ -819,10 +821,12 @@ type StartOptions struct { Env map[string]string ResolvedSecrets []ResolvedSecret BrokerMode bool // When true, auth gathering skips local sources (broker env + filesystem) + DisableLocalAuth bool // When true, auth gathering skips local sources (same as BrokerMode for auth) Detached *bool Resume bool NoAuth bool Branch string + Source string Workspace string GitClone *GitCloneConfig // When set, skip workspace creation; sciontool clones inside container SharedWorkspace bool // When true, workspace is a shared git clone (git-workspace hybrid); skip worktree, configure credential helper diff --git a/pkg/config/harness_config.go b/pkg/config/harness_config.go index d3f9d4402..30fd63a85 100644 --- a/pkg/config/harness_config.go +++ b/pkg/config/harness_config.go @@ -72,6 +72,10 @@ func LoadHarnessConfigDir(dirPath string) (*HarnessConfigDir, error) { return nil, fmt.Errorf("failed to parse config.yaml: %w", err) } + if entry.AuthSelectedType == "" && entry.Auth != nil && entry.Auth.DefaultType != "" { + entry.AuthSelectedType = entry.Auth.DefaultType + } + name := filepath.Base(absPath) if entry.Name != "" { if entry.Name == "." || entry.Name == ".." || strings.ContainsAny(entry.Name, "/\\") { @@ -322,9 +326,9 @@ func mapEmbedFileToHomePath(homeDir, configDir, fileName string) string { return filepath.Join(homeDir, ".codex", "config.toml") case "scion_notify.sh": return filepath.Join(homeDir, ".codex", "scion_notify.sh") - case "opencode.json": + case "opencode.json", "opencode.jsonc": if configDir != "" { - return filepath.Join(homeDir, configDir, "opencode.json") + return filepath.Join(homeDir, configDir, fileName) } return "" default: diff --git a/pkg/config/harness_config_test.go b/pkg/config/harness_config_test.go index d88164580..15bd967e0 100644 --- a/pkg/config/harness_config_test.go +++ b/pkg/config/harness_config_test.go @@ -727,3 +727,17 @@ func TestMapEmbedFileToHarnessConfigPath_RootSupportFiles(t *testing.T) { } } } + +func TestMapEmbedFileToHomePath_OpenCodeConfigs(t *testing.T) { + homeDir := "/tmp/home" + configDir := ".config/opencode" + tests := map[string]string{ + "opencode.json": filepath.Join(homeDir, configDir, "opencode.json"), + "opencode.jsonc": filepath.Join(homeDir, configDir, "opencode.jsonc"), + } + for input, want := range tests { + if got := mapEmbedFileToHomePath(homeDir, configDir, input); got != want { + t.Errorf("mapEmbedFileToHomePath(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/pkg/config/harness_config_upgrade_test.go b/pkg/config/harness_config_upgrade_test.go index 11fcfc725..747a38955 100644 --- a/pkg/config/harness_config_upgrade_test.go +++ b/pkg/config/harness_config_upgrade_test.go @@ -61,4 +61,3 @@ provisioner: t.Errorf("expected no actions, got %d", len(plan.Actions)) } } - diff --git a/pkg/config/main_test.go b/pkg/config/main_test.go new file mode 100644 index 000000000..cc9bd7a90 --- /dev/null +++ b/pkg/config/main_test.go @@ -0,0 +1,13 @@ +package config + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/config/remote_templates.go b/pkg/config/remote_templates.go index 1c83da884..175c538f3 100644 --- a/pkg/config/remote_templates.go +++ b/pkg/config/remote_templates.go @@ -254,7 +254,7 @@ func resolveGitHubRef(ctx context.Context, parts *GitHubURLParts, token string) } cmd := exec.CommandContext(ctx, "git", "ls-remote", "--heads", repoURL) - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=echo") + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") output, err := cmd.Output() if err != nil { return diff --git a/pkg/config/schemas/settings-v1.schema.json b/pkg/config/schemas/settings-v1.schema.json index 04d7446db..c88195093 100644 --- a/pkg/config/schemas/settings-v1.schema.json +++ b/pkg/config/schemas/settings-v1.schema.json @@ -225,6 +225,17 @@ "type": "boolean", "default": false, "description": "Enable GKE-specific features (e.g., Workload Identity)." + }, + "docker": { + "type": "object", + "description": "Docker/Podman-specific runtime settings.", + "properties": { + "network": { + "type": "string", + "description": "Container network mode passed to Docker/Podman --network flag (e.g. 'host', 'bridge', 'none', or a custom network name)." + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -491,7 +502,8 @@ "api_key": { "$ref": "#/$defs/capabilityField" }, "auth_file": { "$ref": "#/$defs/capabilityField" }, "oauth_token": { "$ref": "#/$defs/capabilityField" }, - "vertex_ai": { "$ref": "#/$defs/capabilityField" } + "vertex_ai": { "$ref": "#/$defs/capabilityField" }, + "none": { "$ref": "#/$defs/capabilityField" } }, "additionalProperties": false }, @@ -514,7 +526,7 @@ "properties": { "default_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "none"] }, "types": { "type": "object", @@ -573,14 +585,14 @@ "type": "object", "additionalProperties": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "none"] } }, "files": { "type": "object", "additionalProperties": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "none"] } } }, @@ -644,7 +656,7 @@ "resources": { "$ref": "#/$defs/resourceSpec" }, "auth_selected_type": { "type": "string", - "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai"] + "enum": ["api-key", "oauth-token", "auth-file", "vertex-ai", "none"] } }, "additionalProperties": false diff --git a/pkg/config/settings.go b/pkg/config/settings.go index 9a23b9668..a8c135113 100644 --- a/pkg/config/settings.go +++ b/pkg/config/settings.go @@ -131,17 +131,18 @@ type HubConnectionConfig struct { } type Settings struct { - ProjectID string `json:"project_id,omitempty" yaml:"project_id,omitempty" koanf:"project_id"` - ActiveProfile string `json:"active_profile" yaml:"active_profile" koanf:"active_profile"` - DefaultTemplate string `json:"default_template,omitempty" yaml:"default_template,omitempty" koanf:"default_template"` - WorkspacePath string `json:"workspace_path,omitempty" yaml:"workspace_path,omitempty" koanf:"workspace_path"` - Bucket *BucketConfig `json:"bucket,omitempty" yaml:"bucket,omitempty" koanf:"bucket"` - Hub *HubClientConfig `json:"hub,omitempty" yaml:"hub,omitempty" koanf:"hub"` - CLI *CLIConfig `json:"cli,omitempty" yaml:"cli,omitempty" koanf:"cli"` - HubConnections map[string]HubConnectionConfig `json:"hub_connections,omitempty" yaml:"hub_connections,omitempty" koanf:"hub_connections"` - Runtimes map[string]RuntimeConfig `json:"runtimes" yaml:"runtimes" koanf:"runtimes"` - Harnesses map[string]HarnessConfig `json:"harnesses" yaml:"harnesses" koanf:"harnesses"` - Profiles map[string]ProfileConfig `json:"profiles" yaml:"profiles" koanf:"profiles"` + ProjectID string `json:"project_id,omitempty" yaml:"project_id,omitempty" koanf:"project_id"` + ActiveProfile string `json:"active_profile" yaml:"active_profile" koanf:"active_profile"` + DefaultTemplate string `json:"default_template,omitempty" yaml:"default_template,omitempty" koanf:"default_template"` + WorkspacePath string `json:"workspace_path,omitempty" yaml:"workspace_path,omitempty" koanf:"workspace_path"` + DisableLocalAuth *bool `json:"disable_local_auth,omitempty" yaml:"disable_local_auth,omitempty" koanf:"disable_local_auth"` + Bucket *BucketConfig `json:"bucket,omitempty" yaml:"bucket,omitempty" koanf:"bucket"` + Hub *HubClientConfig `json:"hub,omitempty" yaml:"hub,omitempty" koanf:"hub"` + CLI *CLIConfig `json:"cli,omitempty" yaml:"cli,omitempty" koanf:"cli"` + HubConnections map[string]HubConnectionConfig `json:"hub_connections,omitempty" yaml:"hub_connections,omitempty" koanf:"hub_connections"` + Runtimes map[string]RuntimeConfig `json:"runtimes" yaml:"runtimes" koanf:"runtimes"` + Harnesses map[string]HarnessConfig `json:"harnesses" yaml:"harnesses" koanf:"harnesses"` + Profiles map[string]ProfileConfig `json:"profiles" yaml:"profiles" koanf:"profiles"` } func (s *Settings) ResolveRuntime(profileName string) (RuntimeConfig, string, error) { diff --git a/pkg/config/settings_v1.go b/pkg/config/settings_v1.go index 720c6bc03..a1e9c7597 100644 --- a/pkg/config/settings_v1.go +++ b/pkg/config/settings_v1.go @@ -246,6 +246,7 @@ type VersionedSettings struct { Runtimes map[string]V1RuntimeConfig `json:"runtimes,omitempty" yaml:"runtimes,omitempty" koanf:"runtimes"` ImageRegistry string `json:"image_registry,omitempty" yaml:"image_registry,omitempty" koanf:"image_registry"` WorkspacePath string `json:"workspace_path,omitempty" yaml:"workspace_path,omitempty" koanf:"workspace_path"` + DisableLocalAuth *bool `json:"disable_local_auth,omitempty" yaml:"disable_local_auth,omitempty" koanf:"disable_local_auth"` HarnessConfigs map[string]HarnessConfigEntry `json:"harness_configs,omitempty" yaml:"harness_configs,omitempty" koanf:"harness_configs"` Profiles map[string]V1ProfileConfig `json:"profiles,omitempty" yaml:"profiles,omitempty" koanf:"profiles"` SharedDirs []api.SharedDir `json:"shared_dirs,omitempty" yaml:"shared_dirs,omitempty" koanf:"shared_dirs"` @@ -696,6 +697,11 @@ type V1CloudRunConfig struct { Region string `json:"region,omitempty" yaml:"region,omitempty" koanf:"region"` } +// V1DockerConfig holds Docker-specific runtime settings. +type V1DockerConfig struct { + Network string `json:"network,omitempty" yaml:"network,omitempty" koanf:"network"` +} + // V1RuntimeConfig extends RuntimeConfig with a Type field. type V1RuntimeConfig struct { Type string `json:"type,omitempty" yaml:"type,omitempty" koanf:"type"` @@ -708,6 +714,8 @@ type V1RuntimeConfig struct { ListAllNamespaces bool `json:"list_all_namespaces,omitempty" yaml:"list_all_namespaces,omitempty" koanf:"list_all_namespaces"` // CloudRun holds Cloud Run-specific settings when Type is "cloudrun". CloudRun *V1CloudRunConfig `json:"cloudrun,omitempty" yaml:"cloudrun,omitempty" koanf:"cloudrun"` + // Docker holds Docker-specific settings when Type is "docker" or "podman". + Docker *V1DockerConfig `json:"docker,omitempty" yaml:"docker,omitempty" koanf:"docker"` } // HarnessConfigEntry defines a harness configuration entry in versioned settings. @@ -1766,9 +1774,10 @@ func convertVersionedToLegacy(vs *VersionedSettings) *Settings { } s := &Settings{ - ActiveProfile: vs.ActiveProfile, - DefaultTemplate: vs.DefaultTemplate, - WorkspacePath: vs.WorkspacePath, + ActiveProfile: vs.ActiveProfile, + DefaultTemplate: vs.DefaultTemplate, + WorkspacePath: vs.WorkspacePath, + DisableLocalAuth: vs.DisableLocalAuth, } // Convert Hub diff --git a/pkg/config/settings_v1_test.go b/pkg/config/settings_v1_test.go index fd0bfdf47..d4a1dc34a 100644 --- a/pkg/config/settings_v1_test.go +++ b/pkg/config/settings_v1_test.go @@ -1153,6 +1153,45 @@ func TestResolveRuntime_ProfileEnvMerge(t *testing.T) { assert.Equal(t, "profile_value", rtConfig.Env["PROFILE_KEY"], "profile env should be merged") } +func TestResolveRuntime_DockerNetwork(t *testing.T) { + vs := &VersionedSettings{ + ActiveProfile: "local", + Runtimes: map[string]V1RuntimeConfig{ + "docker": { + Type: "docker", + Docker: &V1DockerConfig{ + Network: "host", + }, + }, + }, + Profiles: map[string]V1ProfileConfig{ + "local": {Runtime: "docker"}, + }, + } + + rtConfig, runtimeType, err := vs.ResolveRuntime("") + require.NoError(t, err) + assert.Equal(t, "docker", runtimeType) + require.NotNil(t, rtConfig.Docker) + assert.Equal(t, "host", rtConfig.Docker.Network) +} + +func TestResolveRuntime_DockerNetworkEmpty(t *testing.T) { + vs := &VersionedSettings{ + ActiveProfile: "local", + Runtimes: map[string]V1RuntimeConfig{ + "docker": {Type: "docker"}, + }, + Profiles: map[string]V1ProfileConfig{ + "local": {Runtime: "docker"}, + }, + } + + rtConfig, _, err := vs.ResolveRuntime("") + require.NoError(t, err) + assert.Nil(t, rtConfig.Docker) +} + func TestResolveRuntime_ProfileNotFound(t *testing.T) { vs := &VersionedSettings{ ActiveProfile: "nonexistent", diff --git a/pkg/config/templates.go b/pkg/config/templates.go index 99b8fbf3d..76b6c8450 100644 --- a/pkg/config/templates.go +++ b/pkg/config/templates.go @@ -489,7 +489,33 @@ func UpdateDefaultTemplates(force bool, harnesses []api.Harness) error { // Seed embed-only harness-configs (e.g. Gemini). for _, h := range harnesses { - if err := SeedHarnessConfig(filepath.Join(harnessConfigsDir, h.Name()), h, true); err != nil { + if err := SeedHarnessConfig(filepath.Join(harnessConfigsDir, h.Name()), h, force); err != nil { + return err + } + } + return nil +} + +// RefreshDefaultTemplates updates templates and harness-configs from embedded +// defaults without failing if they already exist. Used by server startup to +// ensure defaults are present. The force parameter controls whether to overwrite +// existing harness-config files (true) or preserve user customizations (false). +func RefreshDefaultTemplates(harnesses []api.Harness, force bool) error { + globalDir, err := GetGlobalDir() + if err != nil { + return err + } + + harnessConfigsDir := filepath.Join(globalDir, harnessConfigsDirName) + + // Materialize templates with force parameter + if err := MaterializeBundledResources(globalDir, MaterializeOptions{Force: force}); err != nil { + return err + } + + // Seed harness-configs with force parameter + for _, h := range harnesses { + if err := SeedHarnessConfig(filepath.Join(harnessConfigsDir, h.Name()), h, force); err != nil { return err } } diff --git a/pkg/config/templates_test.go b/pkg/config/templates_test.go index a4f43bd63..5383e354c 100644 --- a/pkg/config/templates_test.go +++ b/pkg/config/templates_test.go @@ -220,6 +220,114 @@ func TestUpdateDefaultTemplates(t *testing.T) { } } +func TestRefreshDefaultTemplates_PreservesHarnessConfigCustomizations(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "scion-test-refresh-hc-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + // First call to seed initial harness-configs (force=false) + if err := RefreshDefaultTemplates(GetMockHarnesses(), false); err != nil { + t.Fatalf("expected first refresh to succeed, got: %v", err) + } + + // Find a seeded harness-config and customize it + harnessConfigsDir := filepath.Join(tmpDir, DotScion, harnessConfigsDirName) + entries, err := os.ReadDir(harnessConfigsDir) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("expected at least one harness-config to be seeded") + } + + hcDir := filepath.Join(harnessConfigsDir, entries[0].Name()) + customFile := filepath.Join(hcDir, "home", "custom-user-file.txt") + customContent := "user-customized-content" + if err := os.WriteFile(customFile, []byte(customContent), 0644); err != nil { + t.Fatal(err) + } + + // Call RefreshDefaultTemplates with force=false (simulating server startup) + if err := RefreshDefaultTemplates(GetMockHarnesses(), false); err != nil { + t.Fatalf("expected refresh to succeed, got: %v", err) + } + + // Verify the user's custom file is preserved + data, err := os.ReadFile(customFile) + if err != nil { + t.Fatalf("expected custom file to still exist: %v", err) + } + if string(data) != customContent { + t.Errorf("expected custom file content to be preserved, got: %s", string(data)) + } +} + +func TestRefreshDefaultTemplates_ForceOverwritesCustomizations(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "scion-test-refresh-hc-force-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + origHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", origHome) + + // First call to seed initial harness-configs (force=false) + if err := RefreshDefaultTemplates(GetMockHarnesses(), false); err != nil { + t.Fatalf("expected first refresh to succeed, got: %v", err) + } + + // Find a seeded harness-config and customize an embedded file + harnessConfigsDir := filepath.Join(tmpDir, DotScion, harnessConfigsDirName) + entries, err := os.ReadDir(harnessConfigsDir) + if err != nil { + t.Fatal(err) + } + if len(entries) == 0 { + t.Fatal("expected at least one harness-config to be seeded") + } + + hcDir := filepath.Join(harnessConfigsDir, entries[0].Name()) + configFile := filepath.Join(hcDir, "config.yaml") + + // Read original content + originalData, err := os.ReadFile(configFile) + if err != nil { + t.Fatalf("expected config.yaml to exist: %v", err) + } + originalContent := string(originalData) + + // Modify the embedded file + customContent := "# CUSTOMIZED BY USER\n" + originalContent + if err := os.WriteFile(configFile, []byte(customContent), 0644); err != nil { + t.Fatal(err) + } + + // Call RefreshDefaultTemplates with force=true + if err := RefreshDefaultTemplates(GetMockHarnesses(), true); err != nil { + t.Fatalf("expected refresh with force=true to succeed, got: %v", err) + } + + // Verify the embedded file is restored to original + data, err := os.ReadFile(configFile) + if err != nil { + t.Fatalf("expected config.yaml to still exist: %v", err) + } + if string(data) == customContent { + t.Error("expected config.yaml to be overwritten with force=true, but it still contains custom content") + } + if string(data) != originalContent { + t.Error("expected config.yaml to be restored to original content") + } +} + func TestMergeScionConfig(t *testing.T) { trueVal := true falseVal := false diff --git a/pkg/harness/auth.go b/pkg/harness/auth.go index 1e4c3ba11..5b8ba7c67 100644 --- a/pkg/harness/auth.go +++ b/pkg/harness/auth.go @@ -66,6 +66,7 @@ func GatherAuthWithEnv(env map[string]string, localSources bool, authMeta *confi ClaudeOAuthToken: lookup("CLAUDE_CODE_OAUTH_TOKEN"), OpenAIAPIKey: lookup("OPENAI_API_KEY"), CodexAPIKey: lookup("CODEX_API_KEY"), + OpenCodeAPIKey: lookup("OPENCODE_API_KEY"), GoogleCloudProject: util.FirstNonEmpty( lookup("GOOGLE_CLOUD_PROJECT"), lookup("GCP_PROJECT"), @@ -339,6 +340,10 @@ func RequiredAuthSecrets(harnessName, authSelectedType string, gcpSAAssigned boo effectiveType = "api-key" } + if effectiveType == "none" { + return nil + } + switch harnessName { case "claude", "gemini", "opencode", "codex": if effectiveType == "vertex-ai" && !gcpSAAssigned { @@ -461,6 +466,8 @@ func RequiredAuthEnvKeys(harnessName, authSelectedType string) [][]string { switch harnessName { case "claude": switch effectiveType { + case "none": + return nil case "api-key": return [][]string{{"ANTHROPIC_API_KEY"}} case "oauth-token": @@ -472,6 +479,8 @@ func RequiredAuthEnvKeys(harnessName, authSelectedType string) [][]string { } case "gemini": switch effectiveType { + case "none": + return nil case "api-key": return [][]string{{"GEMINI_API_KEY", "GOOGLE_API_KEY"}} case "vertex-ai": @@ -479,11 +488,15 @@ func RequiredAuthEnvKeys(harnessName, authSelectedType string) [][]string { } case "opencode": switch effectiveType { + case "none": + return nil case "api-key": - return [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}} + return [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENCODE_API_KEY"}} } case "codex": switch effectiveType { + case "none": + return nil case "api-key": return [][]string{{"CODEX_API_KEY", "OPENAI_API_KEY"}} } diff --git a/pkg/harness/auth_test.go b/pkg/harness/auth_test.go index e67696c64..e4de16618 100644 --- a/pkg/harness/auth_test.go +++ b/pkg/harness/auth_test.go @@ -401,7 +401,7 @@ func TestRequiredAuthEnvKeys(t *testing.T) { {"gemini vertex-ai", "gemini", "vertex-ai", [][]string{{"GOOGLE_CLOUD_PROJECT"}, {"GOOGLE_CLOUD_REGION", "CLOUD_ML_REGION", "GOOGLE_CLOUD_LOCATION"}}}, // OpenCode - {"opencode api-key", "opencode", "api-key", [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}}}, + {"opencode api-key", "opencode", "api-key", [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENCODE_API_KEY"}}}, {"opencode auth-file", "opencode", "auth-file", nil}, // Codex @@ -415,7 +415,7 @@ func TestRequiredAuthEnvKeys(t *testing.T) { // Empty authType defaults to api-key {"claude empty auth type", "claude", "", [][]string{{"ANTHROPIC_API_KEY"}}}, {"gemini empty auth type", "gemini", "", [][]string{{"GEMINI_API_KEY", "GOOGLE_API_KEY"}}}, - {"opencode empty auth type", "opencode", "", [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}}}, + {"opencode empty auth type", "opencode", "", [][]string{{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENCODE_API_KEY"}}}, {"codex empty auth type", "codex", "", [][]string{{"CODEX_API_KEY", "OPENAI_API_KEY"}}}, // Unknown/empty diff --git a/pkg/harness/capabilities_test.go b/pkg/harness/capabilities_test.go index d679e4902..25113d379 100644 --- a/pkg/harness/capabilities_test.go +++ b/pkg/harness/capabilities_test.go @@ -67,6 +67,17 @@ func TestAdvancedCapabilitiesDefaults(t *testing.T) { expectSystemPrompt: api.SupportPartial, expectResume: api.SupportNo, }, + { + name: "opencode", + harness: "opencode", + expectMaxTurns: api.SupportYes, + expectMaxModelCalls: api.SupportYes, + expectMaxDuration: api.SupportYes, + expectAuthFile: api.SupportYes, + expectVertexAI: api.SupportYes, + expectSystemPrompt: api.SupportPartial, + expectResume: api.SupportYes, + }, } for _, tc := range tests { diff --git a/pkg/harness/container_script_harness.go b/pkg/harness/container_script_harness.go index e03dc625a..24de17ceb 100644 --- a/pkg/harness/container_script_harness.go +++ b/pkg/harness/container_script_harness.go @@ -225,6 +225,7 @@ func (c *ContainerScriptHarness) ResolveAuth(auth api.AuthConfig) (*api.Resolved addIfPresent("GOOGLE_CLOUD_PROJECT", auth.GoogleCloudProject) addIfPresent("GOOGLE_CLOUD_REGION", auth.GoogleCloudRegion) addIfPresent("CODEX_API_KEY", auth.CodexAPIKey) + addIfPresent("OPENCODE_API_KEY", auth.OpenCodeAPIKey) // Forward config-driven auth env vars. These come from harness config // metadata (auth.types[*].required_env) and are gathered by @@ -359,6 +360,14 @@ func (c *ContainerScriptHarness) Provision(ctx context.Context, agentName, agent } } + // Copy scion-plugin.js if present (used by opencode harness). + pluginSrc := filepath.Join(c.configDirPath, "scion-plugin.js") + if fileExistsHelper(pluginSrc) { + if err := copyHarnessConfigFile(pluginSrc, filepath.Join(bundleHostPath, "scion-plugin.js")); err != nil { + return fmt.Errorf("stage scion-plugin.js: %w", err) + } + } + // Stage the shared scion_harness.py helper next to provision.py so the // in-container script can import it (provision.py adds the bundle dir to // sys.path). diff --git a/pkg/harness/generic.go b/pkg/harness/generic.go index a0d403e6a..1d5f5513e 100644 --- a/pkg/harness/generic.go +++ b/pkg/harness/generic.go @@ -124,6 +124,9 @@ func (g *Generic) ResolveAuth(auth api.AuthConfig) (*api.ResolvedAuth, error) { if auth.CodexAPIKey != "" { result.EnvVars["CODEX_API_KEY"] = auth.CodexAPIKey } + if auth.OpenCodeAPIKey != "" { + result.EnvVars["OPENCODE_API_KEY"] = auth.OpenCodeAPIKey + } if auth.GoogleCloudProject != "" { result.EnvVars["GOOGLE_CLOUD_PROJECT"] = auth.GoogleCloudProject } diff --git a/pkg/harness/generic_test.go b/pkg/harness/generic_test.go index 11e27d1f1..f18537dec 100644 --- a/pkg/harness/generic_test.go +++ b/pkg/harness/generic_test.go @@ -111,6 +111,7 @@ func TestGenericResolveAuth_AllCreds(t *testing.T) { GoogleAPIKey: "google", OpenAIAPIKey: "openai", CodexAPIKey: "codex", + OpenCodeAPIKey: "opencode", GoogleCloudProject: "proj", GoogleCloudRegion: "region", GoogleAppCredentials: "/adc.json", @@ -129,6 +130,7 @@ func TestGenericResolveAuth_AllCreds(t *testing.T) { "GOOGLE_API_KEY": "google", "OPENAI_API_KEY": "openai", "CODEX_API_KEY": "codex", + "OPENCODE_API_KEY": "opencode", "GOOGLE_CLOUD_PROJECT": "proj", "GOOGLE_CLOUD_REGION": "region", } diff --git a/pkg/harness/resolve.go b/pkg/harness/resolve.go index cbc73708e..877a7709b 100644 --- a/pkg/harness/resolve.go +++ b/pkg/harness/resolve.go @@ -38,6 +38,7 @@ type ResolveOptions struct { ProfileName string // active profile (for settings overlay) Settings *config.VersionedSettings // optional settings overlay ConfigDirPath string // explicit harness-config dir (Hub-hydrated path); takes priority over name-based lookup + HarnessAuth string // --harness-auth CLI flag override } // ResolvedHarness is the result of harness.Resolve. The selected @@ -82,6 +83,11 @@ func Resolve(_ context.Context, opts ResolveOptions) (*ResolvedHarness, error) { entry = mergeHarnessConfigEntries(entry, settingsEntry) } + // CLI --harness-auth takes ultimate precedence + if opts.HarnessAuth != "" { + entry.AuthSelectedType = opts.HarnessAuth + } + if entry.Harness == "" { entry.Harness = opts.Name } diff --git a/pkg/hub/controlchannel.go b/pkg/hub/controlchannel.go index 0cf331269..65d5da2f2 100644 --- a/pkg/hub/controlchannel.go +++ b/pkg/hub/controlchannel.go @@ -52,7 +52,7 @@ func DefaultControlChannelConfig() ControlChannelConfig { PingInterval: 30 * time.Second, PongWait: 60 * time.Second, WriteWait: 10 * time.Second, - MaxMessageSize: 64 * 1024, // 64KB + MaxMessageSize: 10 * 1024 * 1024, // 10MB RequestTimeout: 120 * time.Second, Debug: false, } diff --git a/pkg/hub/default_branch.go b/pkg/hub/default_branch.go new file mode 100644 index 000000000..56d86f2a2 --- /dev/null +++ b/pkg/hub/default_branch.go @@ -0,0 +1,107 @@ +// 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 hub + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// parseDefaultBranch extracts the default branch name from `git ls-remote --symref` output. +// The expected format is: "ref: refs/heads/\tHEAD" +// Returns the branch name or empty string if not found. +func parseDefaultBranch(output string) string { + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, "ref: refs/heads/") && strings.Contains(line, "\tHEAD") { + branch := strings.TrimPrefix(line, "ref: refs/heads/") + branch = strings.TrimSuffix(branch, "\tHEAD") + return strings.TrimSpace(branch) + } + } + return "" +} + +// detectDefaultBranch probes a git remote to detect its default branch. +// Returns the branch name or empty string on failure. +func (s *Server) detectDefaultBranch(cloneURL string) string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", cloneURL, "HEAD") + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + output, err := cmd.Output() + if err != nil { + return "" + } + return parseDefaultBranch(string(output)) +} + +// resolveDefaultBranch determines the default branch for a project. +// The git remote is the authoritative source — it always probes the remote +// first so that branch renames (e.g. main → opencode-support) are picked +// up automatically. The stored label is only used as a fallback when the +// remote is unreachable or the detection command fails. +func (s *Server) resolveDefaultBranch(cloneURL, storedBranch string) string { + if cloneURL != "" { + if detected := s.detectDefaultBranch(cloneURL); detected != "" { + return detected + } + } + if storedBranch != "" { + return storedBranch + } + return "main" +} + +// sanitizeGitOutput removes sensitive data (tokens) from git output. +func sanitizeGitOutput(output, token string) string { + if token == "" { + return output + } + return strings.ReplaceAll(output, token, "TOKEN_REDACTED") +} + +// isAuthError checks if git output indicates an authentication failure. +func isAuthError(output string) bool { + lower := strings.ToLower(output) + return strings.Contains(lower, "authentication") || + strings.Contains(lower, "authorization") || + strings.Contains(lower, "403") || + strings.Contains(lower, "401") || + strings.Contains(lower, "access denied") +} + +// buildAuthenticatedURL constructs a git clone URL with embedded credentials. +func buildAuthenticatedURL(cloneURL, token string) string { + if token == "" { + return cloneURL + } + if strings.HasPrefix(cloneURL, "https://") { + if strings.Contains(cloneURL, "github.com") { + return strings.Replace(cloneURL, "https://", "https://"+token+"@", 1) + } + return strings.Replace(cloneURL, "https://", "https://x-access-token:"+token+"@", 1) + } + return cloneURL +} + +// formatCloneError returns a user-friendly error for git clone failures. +func formatCloneError(output, token string) error { + sanitized := sanitizeGitOutput(output, token) + return fmt.Errorf("git clone failed: %s", strings.TrimSpace(sanitized)) +} diff --git a/pkg/hub/events.go b/pkg/hub/events.go index c8b7b9859..a031b42f1 100644 --- a/pkg/hub/events.go +++ b/pkg/hub/events.go @@ -345,6 +345,10 @@ func (p *ChannelEventPublisher) Close() { // PublishAgentStatus publishes an agent status event to both agent-specific // and project-scoped subjects (dual-publish pattern). func (p *eventBuilder) PublishAgentStatus(_ context.Context, agent *store.Agent) { + slog.Debug("PublishAgentStatus", + "agent_id", agent.ID, "project_id", agent.ProjectID, + "phase", agent.Phase, "activity", agent.Activity) + evt := AgentStatusEvent{ AgentID: agent.ID, ProjectID: agent.ProjectID, diff --git a/pkg/hub/handlers_agent_create_helpers.go b/pkg/hub/handlers_agent_create_helpers.go index 5975ed6d9..68c311eec 100644 --- a/pkg/hub/handlers_agent_create_helpers.go +++ b/pkg/hub/handlers_agent_create_helpers.go @@ -84,6 +84,7 @@ func (s *Server) buildAppliedConfig(req CreateAgentRequest, harnessConfig string Task: req.Task, Attach: req.Attach, Branch: req.Branch, + Source: req.Source, Workspace: req.Workspace, CreatorName: creatorName, } @@ -130,10 +131,7 @@ func (s *Server) populateAgentConfig(ctx context.Context, agent *store.Agent, pr // Shared-workspace git projects skip clone — agents mount the shared workspace instead. if project != nil && project.GitRemote != "" && !project.IsSharedWorkspace() { cloneURL := resolveCloneURL(project.Labels["scion.dev/clone-url"], project.GitRemote) - defaultBranch := project.Labels["scion.dev/default-branch"] - if defaultBranch == "" { - defaultBranch = "main" - } + defaultBranch := s.resolveDefaultBranch(cloneURL, project.Labels["scion.dev/default-branch"]) agent.AppliedConfig.GitClone = &api.GitCloneConfig{ URL: cloneURL, Branch: defaultBranch, @@ -152,10 +150,8 @@ func (s *Server) populateAgentConfig(ctx context.Context, agent *store.Agent, pr // For shared-workspace git projects, default the branch to the project's // default branch (the workspace's current branch) instead of the agent slug. if project != nil && project.IsSharedWorkspace() && agent.AppliedConfig.Branch == "" { - defaultBranch := project.Labels["scion.dev/default-branch"] - if defaultBranch == "" { - defaultBranch = "main" - } + cloneURL := resolveCloneURL(project.Labels["scion.dev/clone-url"], project.GitRemote) + defaultBranch := s.resolveDefaultBranch(cloneURL, project.Labels["scion.dev/default-branch"]) agent.AppliedConfig.Branch = defaultBranch } diff --git a/pkg/hub/handlers_agent_lifecycle.go b/pkg/hub/handlers_agent_lifecycle.go index a04116095..c9010b948 100644 --- a/pkg/hub/handlers_agent_lifecycle.go +++ b/pkg/hub/handlers_agent_lifecycle.go @@ -69,6 +69,9 @@ func (s *Server) updateAgentStatus(w http.ResponseWriter, r *http.Request, id st return } + s.agentLifecycleLog.Debug("agent status updated", + "agent_id", id, "phase", status.Phase, "activity", status.Activity) + // Publish status event (best-effort: fetch agent for ProjectID) if agent, err := s.store.GetAgent(ctx, id); err == nil { s.events.PublishAgentStatus(ctx, agent) diff --git a/pkg/hub/handlers_agents_core.go b/pkg/hub/handlers_agents_core.go index 49abd9fbf..b7efe9092 100644 --- a/pkg/hub/handlers_agents_core.go +++ b/pkg/hub/handlers_agents_core.go @@ -74,6 +74,7 @@ type CreateAgentRequest struct { Profile string `json:"profile,omitempty"` // Settings profile for the runtime broker to use Task string `json:"task,omitempty"` Branch string `json:"branch,omitempty"` + Source string `json:"source,omitempty"` Workspace string `json:"workspace,omitempty"` Labels map[string]string `json:"labels,omitempty"` Config *api.ScionConfig `json:"config,omitempty"` diff --git a/pkg/hub/handlers_projects_core.go b/pkg/hub/handlers_projects_core.go index 328925125..03ae44a32 100644 --- a/pkg/hub/handlers_projects_core.go +++ b/pkg/hub/handlers_projects_core.go @@ -741,10 +741,7 @@ func (s *Server) cloneSharedWorkspaceProject(ctx context.Context, project *store // Only convert to HTTPS if the URL looks like a remote git URL. cloneURL := resolveCloneURL(project.Labels["scion.dev/clone-url"], project.GitRemote) - defaultBranch := project.Labels["scion.dev/default-branch"] - if defaultBranch == "" { - defaultBranch = "main" - } + defaultBranch := s.resolveDefaultBranch(cloneURL, project.Labels["scion.dev/default-branch"]) // Resolve a token for authentication. token := s.resolveCloneToken(ctx, project) diff --git a/pkg/hub/harness_config_bootstrap_test.go b/pkg/hub/harness_config_bootstrap_test.go index 9aa501e2f..75e904e3f 100644 --- a/pkg/hub/harness_config_bootstrap_test.go +++ b/pkg/hub/harness_config_bootstrap_test.go @@ -86,8 +86,8 @@ func TestBootstrapHarnessConfigsFromDir_ImportsConfigs(t *testing.T) { if hc.ContentHash == "" { t.Error("expected non-empty content hash") } - if len(stor.objects) != 3 { - t.Errorf("expected 3 objects in storage, got %d", len(stor.objects)) + if len(stor.objects) != 4 { + t.Errorf("expected 4 objects in storage (3 files + manifest.json), got %d", len(stor.objects)) } } diff --git a/pkg/hub/main_test.go b/pkg/hub/main_test.go new file mode 100644 index 000000000..b0178b101 --- /dev/null +++ b/pkg/hub/main_test.go @@ -0,0 +1,13 @@ +package hub + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/hub/notifications.go b/pkg/hub/notifications.go index ca14dc5f7..3a1a90c0e 100644 --- a/pkg/hub/notifications.go +++ b/pkg/hub/notifications.go @@ -115,6 +115,10 @@ func (nd *NotificationDispatcher) handleEvent(evt Event) { return } + nd.log.Debug("notification dispatcher received agent status event", + "agent_id", statusEvt.AgentID, "project_id", statusEvt.ProjectID, + "phase", statusEvt.Phase, "activity", statusEvt.Activity) + ctx := context.Background() // Collect subscriptions from both scopes: agent-scoped first (more specific), diff --git a/pkg/hub/resource_store.go b/pkg/hub/resource_store.go index 3fa1d23ff..5ac4eb399 100644 --- a/pkg/hub/resource_store.go +++ b/pkg/hub/resource_store.go @@ -171,8 +171,19 @@ func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeI if err != nil { return false, err } + + // Write manifest.json to storage + manifest := &transfer.Manifest{ + Version: "1.0", + ContentHash: computeContentHash(uploaded), + Files: toTransferFileInfos(uploaded), + } + if err := writeManifestToStorage(ctx, stor, storagePath, manifest); err != nil { + return false, fmt.Errorf("failed to write manifest.json: %w", err) + } + rec.Files = uploaded - rec.ContentHash = computeContentHash(uploaded) + rec.ContentHash = manifest.ContentHash rec.Status = resourceStatusActive if err := p.Update(ctx, rec, dir); err != nil { return false, err @@ -187,6 +198,28 @@ func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeI // Existing resource — short-circuit on unchanged content unless forced. if !force { if computeContentHash(toResourceFiles(files)) == existing.ContentHash { + // Check if manifest.json is missing and write it if needed + storagePath := existing.StoragePath + if storagePath == "" { + storagePath = storage.ResourceStoragePath(kind, existing.Scope, existing.ScopeID, existing.Slug) + } + manifestPath := storagePath + "/manifest.json" + exists, err := stor.Exists(ctx, manifestPath) + if err == nil && !exists { + // Write missing manifest.json + manifest := &transfer.Manifest{ + Version: "1.0", + ContentHash: existing.ContentHash, + Files: toTransferFileInfos(existing.Files), + } + if err := writeManifestToStorage(ctx, stor, storagePath, manifest); err != nil { + srv.resourceLog.Warn(p.Label()+": failed to write missing manifest.json", + "name", existing.Name, "error", err) + } else { + srv.resourceLog.Info(p.Label()+": wrote missing manifest.json", + "name", existing.Name) + } + } return p.OnHashMatch(ctx, existing, dir) } } @@ -201,6 +234,19 @@ func (rs *ResourceStore) Bootstrap(ctx context.Context, name, dir, scope, scopeI return false, err } + // Write manifest.json to storage + manifest := &transfer.Manifest{ + Version: "1.0", + ContentHash: computeContentHash(uploaded), + Files: toTransferFileInfos(uploaded), + } + if err := writeManifestToStorage(ctx, stor, storagePath, manifest); err != nil { + return false, fmt.Errorf("failed to write manifest.json: %w", err) + } + + // Add manifest.json to the written set so reconcile doesn't delete it + written[storagePath+"/manifest.json"] = struct{}{} + // Reconcile storage: drop objects no longer in the manifest so removed files // don't linger. (Templates already did this on sync; harness-configs gain it // by routing through the shared path — a removed-file cleanup fix.) diff --git a/pkg/hub/resource_validate.go b/pkg/hub/resource_validate.go index 236985959..3dfc426f2 100644 --- a/pkg/hub/resource_validate.go +++ b/pkg/hub/resource_validate.go @@ -47,6 +47,7 @@ const ( ValidationIssueMissingManifest = "missing_manifest" ValidationIssueZeroFilesActive = "zero_files_active" ValidationIssueContentHashMismatch = "content_hash_mismatch" + ValidationIssueStorageEmpty = "storage_empty" ) // ValidateStorage checks a resource record's storage consistency. It verifies: @@ -78,16 +79,15 @@ func (rs *ResourceStore) ValidateStorage(ctx context.Context, rec *ResourceRecor storagePath = storage.ResourceStoragePath(rec.Kind, rec.Scope, rec.ScopeID, rec.Slug) } + missingCount := 0 + mismatchCount := 0 + for _, file := range rec.Files { objectPath := storagePath + "/" + file.Path obj, err := stor.GetObject(ctx, objectPath) if err != nil { if errors.Is(err, storage.ErrNotFound) { - report.Issues = append(report.Issues, ValidationIssue{ - Kind: ValidationIssueMissingObject, - File: file.Path, - Message: fmt.Sprintf("storage object missing for file %q", file.Path), - }) + missingCount++ continue } return report, fmt.Errorf("checking object %q: %w", objectPath, err) @@ -106,23 +106,27 @@ func (rs *ResourceStore) ValidateStorage(ctx context.Context, rec *ResourceRecor } } if storedHash != "" && storedHash != file.Hash { - report.Issues = append(report.Issues, ValidationIssue{ - Kind: ValidationIssueContentHashMismatch, - File: file.Path, - Message: fmt.Sprintf("expected %s, got %s", file.Hash, storedHash), - }) + mismatchCount++ } } - manifestPath := storagePath + "/manifest.json" - exists, err := stor.Exists(ctx, manifestPath) - if err != nil { - return report, fmt.Errorf("checking manifest: %w", err) + // Report high-level issues first + if missingCount == len(rec.Files) && len(rec.Files) > 0 { + report.Issues = append(report.Issues, ValidationIssue{ + Kind: ValidationIssueStorageEmpty, + Message: fmt.Sprintf("storage is empty (%d files missing), run 'scion %s sync' to populate", len(rec.Files), rec.Kind), + }) + } else if missingCount > 0 { + report.Issues = append(report.Issues, ValidationIssue{ + Kind: ValidationIssueMissingObject, + Message: fmt.Sprintf("%d of %d files missing from storage", missingCount, len(rec.Files)), + }) } - if !exists { + + if mismatchCount > 0 { report.Issues = append(report.Issues, ValidationIssue{ - Kind: ValidationIssueMissingManifest, - Message: "manifest.json missing from storage", + Kind: ValidationIssueContentHashMismatch, + Message: fmt.Sprintf("%d files have outdated content in storage, run 'scion %s sync' to update", mismatchCount, rec.Kind), }) } diff --git a/pkg/hub/resource_validate_test.go b/pkg/hub/resource_validate_test.go index 33ae4c9bb..9d61498f0 100644 --- a/pkg/hub/resource_validate_test.go +++ b/pkg/hub/resource_validate_test.go @@ -19,6 +19,7 @@ package hub import ( "bytes" "context" + "strings" "testing" "github.com/GoogleCloudPlatform/scion/pkg/storage" @@ -104,13 +105,13 @@ func TestValidateStorage_MissingObject(t *testing.T) { found := false for _, issue := range report.Issues { - if issue.Kind == ValidationIssueMissingObject && issue.File == "home/.bashrc" { + if issue.Kind == ValidationIssueMissingObject && strings.Contains(issue.Message, "1 of 2 files missing") { found = true break } } if !found { - t.Errorf("expected missing_object issue for home/.bashrc, got: %v", report.Issues) + t.Errorf("expected missing_object issue with summary message, got: %v", report.Issues) } } @@ -146,15 +147,9 @@ func TestValidateStorage_MissingManifest(t *testing.T) { t.Fatalf("ValidateStorage failed: %v", err) } - found := false - for _, issue := range report.Issues { - if issue.Kind == ValidationIssueMissingManifest { - found = true - break - } - } - if !found { - t.Errorf("expected missing_manifest issue, got: %v", report.Issues) + // After removing manifest check, this should pass if files are valid + if len(report.Issues) > 0 { + t.Errorf("expected no issues, got: %v", report.Issues) } } @@ -243,13 +238,13 @@ func TestValidateStorage_ContentHashMismatch(t *testing.T) { found := false for _, issue := range report.Issues { - if issue.Kind == ValidationIssueContentHashMismatch && issue.File == "home/.bashrc" { + if issue.Kind == ValidationIssueContentHashMismatch && strings.Contains(issue.Message, "1 files have outdated content") { found = true break } } if !found { - t.Errorf("expected content_hash_mismatch issue for home/.bashrc, got: %v", report.Issues) + t.Errorf("expected content_hash_mismatch issue with summary message, got: %v", report.Issues) } } diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 8b3cee48d..cc7289296 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -844,7 +844,7 @@ func New(cfg ServerConfig, s store.Store) (*Server, error) { PingInterval: 30 * time.Second, PongWait: 60 * time.Second, WriteWait: 10 * time.Second, - MaxMessageSize: 64 * 1024, + MaxMessageSize: 10 * 1024 * 1024, RequestTimeout: 120 * time.Second, Debug: cfg.Debug, }, logging.Subsystem("hub.control-channel")) diff --git a/pkg/hub/storage_helpers.go b/pkg/hub/storage_helpers.go index 7950727bd..8ab342ea1 100644 --- a/pkg/hub/storage_helpers.go +++ b/pkg/hub/storage_helpers.go @@ -15,7 +15,9 @@ package hub import ( + "bytes" "context" + "encoding/json" "fmt" "log/slog" "net/http" @@ -200,6 +202,38 @@ func uploadResourceFiles(ctx context.Context, stor storage.Storage, storagePath return uploaded, written, nil } +// toTransferFileInfos converts store.TemplateFile slice to transfer.FileInfo slice. +func toTransferFileInfos(files []store.TemplateFile) []transfer.FileInfo { + result := make([]transfer.FileInfo, len(files)) + for i, f := range files { + result[i] = transfer.FileInfo{ + Path: f.Path, + Size: f.Size, + Hash: f.Hash, + Mode: f.Mode, + } + } + return result +} + +// writeManifestToStorage writes a manifest.json file to storage at the given path. +func writeManifestToStorage(ctx context.Context, stor storage.Storage, storagePath string, manifest *transfer.Manifest) error { + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to marshal manifest: %w", err) + } + + manifestPath := storagePath + "/manifest.json" + _, err = stor.Upload(ctx, manifestPath, bytes.NewReader(manifestBytes), storage.UploadOptions{ + ContentType: "application/json", + }) + if err != nil { + return fmt.Errorf("failed to upload manifest.json: %w", err) + } + + return nil +} + // reconcileResourceStorage deletes objects under storagePath that are not in the // keep set, so files removed from a resource don't linger in storage after a // re-sync. List/delete failures are logged and skipped (best-effort), matching diff --git a/pkg/hub/template_bootstrap_test.go b/pkg/hub/template_bootstrap_test.go index 4f1d61018..670001936 100644 --- a/pkg/hub/template_bootstrap_test.go +++ b/pkg/hub/template_bootstrap_test.go @@ -121,8 +121,8 @@ func TestBootstrapTemplatesFromDir_ImportsTemplates(t *testing.T) { } // Verify files were uploaded to storage - if len(stor.objects) != 2 { - t.Errorf("expected 2 objects in storage, got %d", len(stor.objects)) + if len(stor.objects) != 3 { + t.Errorf("expected 3 objects in storage (2 files + manifest.json), got %d", len(stor.objects)) } } @@ -160,8 +160,8 @@ func TestBootstrapTemplatesFromDir_ImportsNewAlongsideExisting(t *testing.T) { } // Verify the new template files were uploaded - if len(stor.objects) != 1 { - t.Errorf("expected 1 object in storage (new template file), got %d", len(stor.objects)) + if len(stor.objects) != 2 { + t.Errorf("expected 2 objects in storage (1 file + manifest.json), got %d", len(stor.objects)) } } @@ -400,8 +400,8 @@ func TestSyncExistingTemplate_ForceReconcilesStorage(t *testing.T) { t.Fatalf("get template: %v", err) } originalHash := existing.ContentHash - if len(stor.objects) != 3 { - t.Fatalf("expected 3 storage objects after bootstrap, got %d", len(stor.objects)) + if len(stor.objects) != 4 { + t.Fatalf("expected 4 storage objects after bootstrap (3 files + manifest.json), got %d", len(stor.objects)) } // Modify the source: update one file, delete one, add a new one. @@ -452,8 +452,8 @@ func TestSyncExistingTemplate_ForceReconcilesStorage(t *testing.T) { if _, exists := stor.objects[storagePath+"/file-update.txt"]; !exists { t.Error("expected file-update.txt to remain in storage after re-upload") } - if len(stor.objects) != 3 { - t.Errorf("expected 3 storage objects after reconcile, got %d", len(stor.objects)) + if len(stor.objects) != 4 { + t.Errorf("expected 4 storage objects after reconcile (3 files + manifest.json), got %d", len(stor.objects)) } } @@ -1260,8 +1260,8 @@ system_prompt: system-prompt.md } // Verify files uploaded to storage - if len(stor.objects) != 3 { - t.Errorf("expected 3 files uploaded to storage, got %d", len(stor.objects)) + if len(stor.objects) != 4 { + t.Errorf("expected 4 files uploaded to storage (3 files + manifest.json), got %d", len(stor.objects)) } } @@ -1354,8 +1354,8 @@ func TestImportHarnessConfigsFromRemote_WithProjectGithubToken(t *testing.T) { if existing.Harness != "claude" { t.Errorf("expected harness 'claude', got %q", existing.Harness) } - if len(stor.objects) != 2 { - t.Errorf("expected 2 files uploaded to storage, got %d", len(stor.objects)) + if len(stor.objects) != 3 { + t.Errorf("expected 3 files uploaded to storage (2 files + manifest.json), got %d", len(stor.objects)) } } diff --git a/pkg/hubclient/agents.go b/pkg/hubclient/agents.go index 6683c64d6..4ff35f584 100644 --- a/pkg/hubclient/agents.go +++ b/pkg/hubclient/agents.go @@ -167,6 +167,7 @@ type CreateAgentRequest struct { Profile string `json:"profile,omitempty"` Task string `json:"task,omitempty"` Branch string `json:"branch,omitempty"` + Source string `json:"source,omitempty"` Workspace string `json:"workspace,omitempty"` Labels map[string]string `json:"labels,omitempty"` Annotations map[string]string `json:"annotations,omitempty"` diff --git a/pkg/provision/main_test.go b/pkg/provision/main_test.go new file mode 100644 index 000000000..b59764655 --- /dev/null +++ b/pkg/provision/main_test.go @@ -0,0 +1,13 @@ +package provision + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/runtime/common_test.go b/pkg/runtime/common_test.go index 75dbb9c39..1bc3116eb 100644 --- a/pkg/runtime/common_test.go +++ b/pkg/runtime/common_test.go @@ -1117,12 +1117,13 @@ func TestResolveHostNetworking(t *testing.T) { name string runtimeName string env map[string]string - forceHost bool // set SCION_FORCE_HOST_NETWORK for this case wantMode string wantEP string // expected SCION_HUB_ENDPOINT after call (empty = unchanged/absent) + wantURL string // expected SCION_HUB_URL after call (empty = unchanged/absent) + forceHost bool // set SCION_FORCE_HOST_NETWORK for this case }{ { - name: "docker with bridge hostname rewrites to localhost", + name: "docker with bridge hostname uses host networking", runtimeName: "docker", env: map[string]string{ "SCION_HUB_ENDPOINT": "http://host.docker.internal:8080", @@ -1130,6 +1131,7 @@ func TestResolveHostNetworking(t *testing.T) { }, wantMode: "host", wantEP: "http://localhost:8080", + wantURL: "http://localhost:8080", }, { name: "docker with localhost endpoint", @@ -1183,13 +1185,14 @@ func TestResolveHostNetworking(t *testing.T) { wantEP: "http://localhost:8080", }, { - name: "docker with bridge hostname in HUB_URL only", + name: "docker with bridge hostname in HUB_URL uses host networking", runtimeName: "docker", env: map[string]string{ "SCION_HUB_URL": "http://host.docker.internal:9090", }, wantMode: "host", - wantEP: "", // SCION_HUB_ENDPOINT not set + wantEP: "", + wantURL: "http://localhost:9090", }, { name: "force-host overrides domain endpoint", @@ -1268,6 +1271,11 @@ func TestResolveHostNetworking(t *testing.T) { t.Errorf("SCION_HUB_ENDPOINT = %q, want %q", ep, tt.wantEP) } } + if tt.wantURL != "" { + if u := env["SCION_HUB_URL"]; u != tt.wantURL { + t.Errorf("SCION_HUB_URL = %q, want %q", u, tt.wantURL) + } + } // Verify HUB_URL is also rewritten when bridge hostname was present if tt.runtimeName == "docker" && tt.wantMode == "host" { if hubURL, ok := env["SCION_HUB_URL"]; ok && strings.Contains(hubURL, "host.docker.internal") { diff --git a/pkg/runtime/docker.go b/pkg/runtime/docker.go index 9b5e31aaa..b2f9e644d 100644 --- a/pkg/runtime/docker.go +++ b/pkg/runtime/docker.go @@ -31,6 +31,7 @@ import ( type DockerRuntime struct { Command string Host string + Network string } func NewDockerRuntime() *DockerRuntime { @@ -48,6 +49,10 @@ func (r *DockerRuntime) ExecUser() string { } func (r *DockerRuntime) Run(ctx context.Context, config RunConfig) (string, error) { + if config.NetworkMode == "" && r.Network != "" { + config.NetworkMode = r.Network + } + // Serialize file and variable secrets into an env-var blob for // container-side staging by sciontool init (stateless broker support). if len(config.ResolvedSecrets) > 0 { diff --git a/pkg/runtime/docker_test.go b/pkg/runtime/docker_test.go index 27fd316cd..c2d58bcef 100644 --- a/pkg/runtime/docker_test.go +++ b/pkg/runtime/docker_test.go @@ -63,6 +63,93 @@ echo "$@" } } +func TestDockerRuntime_NetworkConfig(t *testing.T) { + tmpDir := t.TempDir() + mockDocker := filepath.Join(tmpDir, "mock-docker") + + script := `#!/bin/sh +echo "$@" +` + if err := os.WriteFile(mockDocker, []byte(script), 0755); err != nil { + t.Fatalf("failed to write mock docker: %v", err) + } + + t.Run("runtime network applied when config NetworkMode is empty", func(t *testing.T) { + rt := &DockerRuntime{ + Command: mockDocker, + Network: "bridge", + } + + config := RunConfig{ + Harness: &harness.Generic{}, + Name: "test-agent", + UnixUsername: "scion", + Image: "scion-agent:latest", + Task: "hello", + } + + out, err := rt.Run(context.Background(), config) + if err != nil { + t.Fatalf("runtime.Run failed: %v", err) + } + + if !strings.Contains(out, "--network bridge") { + t.Errorf("expected '--network bridge' in output, got %q", out) + } + }) + + t.Run("config NetworkMode takes precedence over runtime network", func(t *testing.T) { + rt := &DockerRuntime{ + Command: mockDocker, + Network: "bridge", + } + + config := RunConfig{ + Harness: &harness.Generic{}, + Name: "test-agent", + UnixUsername: "scion", + Image: "scion-agent:latest", + Task: "hello", + NetworkMode: "host", + } + + out, err := rt.Run(context.Background(), config) + if err != nil { + t.Fatalf("runtime.Run failed: %v", err) + } + + if !strings.Contains(out, "--network host") { + t.Errorf("expected '--network host' in output, got %q", out) + } + if strings.Contains(out, "--network bridge") { + t.Errorf("expected '--network bridge' to be absent, got %q", out) + } + }) + + t.Run("no network flag when both are empty", func(t *testing.T) { + rt := &DockerRuntime{ + Command: mockDocker, + } + + config := RunConfig{ + Harness: &harness.Generic{}, + Name: "test-agent", + UnixUsername: "scion", + Image: "scion-agent:latest", + Task: "hello", + } + + out, err := rt.Run(context.Background(), config) + if err != nil { + t.Fatalf("runtime.Run failed: %v", err) + } + + if strings.Contains(out, "--network") { + t.Errorf("expected no '--network' flag, got %q", out) + } + }) +} + func TestDockerRuntime_Exec_UserFlag(t *testing.T) { // Create a temporary script to act as a mock docker tmpDir := t.TempDir() diff --git a/pkg/runtime/factory.go b/pkg/runtime/factory.go index f7d862c4c..31eafc493 100644 --- a/pkg/runtime/factory.go +++ b/pkg/runtime/factory.go @@ -102,6 +102,9 @@ func GetRuntime(projectPath string, profileName string) Runtime { if rtConfig.Host != "" { dr.Host = rtConfig.Host } + if rtConfig.Docker != nil && rtConfig.Docker.Network != "" { + dr.Network = rtConfig.Docker.Network + } return dr case "podman": pr := NewPodmanRuntime() diff --git a/pkg/runtime/main_test.go b/pkg/runtime/main_test.go new file mode 100644 index 000000000..8e4eb497f --- /dev/null +++ b/pkg/runtime/main_test.go @@ -0,0 +1,13 @@ +package runtime + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/runtimebroker/main_test.go b/pkg/runtimebroker/main_test.go new file mode 100644 index 000000000..f085290c1 --- /dev/null +++ b/pkg/runtimebroker/main_test.go @@ -0,0 +1,13 @@ +package runtimebroker + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/runtimebroker/start_context.go b/pkg/runtimebroker/start_context.go index e93f53b0f..8a9a7f335 100644 --- a/pkg/runtimebroker/start_context.go +++ b/pkg/runtimebroker/start_context.go @@ -386,6 +386,7 @@ func (s *Server) buildStartContext(ctx context.Context, in startContextInputs) ( opts.Workspace = in.Config.Workspace opts.Profile = in.Config.Profile opts.Branch = in.Config.Branch + opts.Source = in.Config.Source opts.SharedWorkspace = in.Config.SharedWorkspace } diff --git a/pkg/runtimebroker/types.go b/pkg/runtimebroker/types.go index 7c6f0a2e6..ce8b2707c 100644 --- a/pkg/runtimebroker/types.go +++ b/pkg/runtimebroker/types.go @@ -389,6 +389,7 @@ type CreateAgentConfig struct { CommandArgs []string `json:"commandArgs,omitempty"` Profile string `json:"profile,omitempty"` // Settings profile for the runtime broker Branch string `json:"branch,omitempty"` // Git branch name (defaults to agent slug if empty) + Source string `json:"source,omitempty"` // Git source commit-ish (branch, tag, or commit) Kubernetes *api.KubernetesConfig `json:"kubernetes,omitempty"` // TemplateID is the Hub template ID for cache lookup. diff --git a/pkg/sciontool/hooks/dialects/opencode.go b/pkg/sciontool/hooks/dialects/opencode.go new file mode 100644 index 000000000..f45f87009 --- /dev/null +++ b/pkg/sciontool/hooks/dialects/opencode.go @@ -0,0 +1,108 @@ +/* +Copyright 2025 The Scion Authors. +*/ + +package dialects + +import ( + "fmt" + + "github.com/GoogleCloudPlatform/scion/pkg/sciontool/hooks" +) + +// OpenCodeDialect parses events emitted by the scion-plugin.js OpenCode plugin. +// +// The plugin emits pre-normalized events in the following format: +// +// { +// "name": "tool-start" | "tool-end" | "session-start" | etc., +// "tool_name": "...", +// "prompt": "...", +// "success": true, +// ... +// } +// +// Unlike Claude/Gemini/Codex dialects which normalize harness-specific event +// names (e.g., "PreToolUse" -> "tool-start"), the opencode dialect receives +// already-normalized event names and passes them through directly. +type OpenCodeDialect struct{} + +// NewOpenCodeDialect creates a new OpenCode dialect parser. +func NewOpenCodeDialect() *OpenCodeDialect { + return &OpenCodeDialect{} +} + +// Name returns the dialect name. +func (d *OpenCodeDialect) Name() string { + return "opencode" +} + +// Parse converts OpenCode plugin event format to normalized Event. +func (d *OpenCodeDialect) Parse(data map[string]interface{}) (*hooks.Event, error) { + rawName := getString(data, "name") + if rawName == "" { + rawName = getString(data, "hook_event_name") + } + if rawName == "" { + return nil, fmt.Errorf("opencode: missing event name") + } + + payload := data + if nested, ok := data["data"]; ok { + if m, ok := nested.(map[string]interface{}); ok && len(m) > 0 { + payload = m + } + } + + event := &hooks.Event{ + Name: d.normalizeEventName(rawName), + RawName: rawName, + Dialect: "opencode", + Data: hooks.EventData{ + Prompt: getString(payload, "prompt"), + ToolName: getString(payload, "tool_name"), + Message: getString(payload, "message"), + Reason: getString(payload, "reason"), + Source: getString(payload, "source"), + SessionID: getString(payload, "session_id"), + Success: getBool(payload, "success"), + Error: getString(payload, "error"), + AssistantText: getString(payload, "assistant_text"), + Raw: payload, + }, + } + + if val, ok := payload["tool_input"]; ok { + if str, ok := val.(string); ok { + event.Data.ToolInput = str + } + } + if val, ok := payload["tool_output"]; ok { + if str, ok := val.(string); ok { + event.Data.ToolOutput = str + } + } + + extractTokens(payload, &event.Data) + + extractFilePath(payload, &event.Data) + + if isHB, _ := data["_scion_heartbeat"].(bool); isHB { + if event.Data.Raw == nil { + event.Data.Raw = make(map[string]interface{}) + } + event.Data.Raw["_scion_heartbeat"] = true + } + + return event, nil +} + +// normalizeEventName passes through pre-normalized event names from the plugin. +func (d *OpenCodeDialect) normalizeEventName(name string) string { + switch name { + case "_activity": + return "" + default: + return name + } +} diff --git a/pkg/sciontool/hooks/dialects/opencode_test.go b/pkg/sciontool/hooks/dialects/opencode_test.go new file mode 100644 index 000000000..926ed854d --- /dev/null +++ b/pkg/sciontool/hooks/dialects/opencode_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2025 The Scion Authors. +*/ + +package dialects + +import ( + "testing" + + "github.com/GoogleCloudPlatform/scion/pkg/sciontool/hooks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenCodeDialect_Name(t *testing.T) { + d := NewOpenCodeDialect() + assert.Equal(t, "opencode", d.Name()) +} + +func TestOpenCodeDialect_NestedFormat(t *testing.T) { + d := NewOpenCodeDialect() + + tests := []struct { + rawName string + wantName string + payload map[string]interface{} + }{ + { + rawName: "tool-start", + wantName: hooks.EventToolStart, + payload: map[string]interface{}{"tool_name": "Bash"}, + }, + { + rawName: "tool-end", + wantName: hooks.EventToolEnd, + payload: map[string]interface{}{"tool_name": "Bash", "success": true}, + }, + { + rawName: "session-start", + wantName: hooks.EventSessionStart, + payload: map[string]interface{}{"session_id": "abc123"}, + }, + { + rawName: "session-end", + wantName: hooks.EventSessionEnd, + payload: map[string]interface{}{"reason": "user-requested"}, + }, + { + rawName: "prompt-submit", + wantName: hooks.EventPromptSubmit, + payload: map[string]interface{}{"prompt": "Fix the bug"}, + }, + { + rawName: "notification", + wantName: hooks.EventNotification, + payload: map[string]interface{}{"message": "Need input"}, + }, + { + rawName: "model-start", + wantName: hooks.EventModelStart, + payload: map[string]interface{}{}, + }, + { + rawName: "model-end", + wantName: hooks.EventModelEnd, + payload: map[string]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.rawName, func(t *testing.T) { + data := map[string]interface{}{ + "name": tt.rawName, + "data": tt.payload, + } + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, tt.wantName, event.Name) + assert.Equal(t, "opencode", event.Dialect) + }) + } +} + +func TestOpenCodeDialect_FlatFormat(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "tool-start", + "tool_name": "Read", + "file_path": "/src/main.go", + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, hooks.EventToolStart, event.Name) + assert.Equal(t, "Read", event.Data.ToolName) + assert.Equal(t, "/src/main.go", event.Data.FilePath) +} + +func TestOpenCodeDialect_ToolSuccessError(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "tool-end", + "data": map[string]interface{}{ + "tool_name": "Bash", + "success": false, + "error": "exit status 1", + }, + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.False(t, event.Data.Success) + assert.Equal(t, "exit status 1", event.Data.Error) + assert.Equal(t, "Bash", event.Data.ToolName) +} + +func TestOpenCodeDialect_ToolInputOutput(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "tool-end", + "tool_name": "Bash", + "tool_input": "ls -la", + "tool_output": "total 42\n...", + "success": true, + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, "ls -la", event.Data.ToolInput) + assert.Equal(t, "total 42\n...", event.Data.ToolOutput) +} + +func TestOpenCodeDialect_SessionEndAssistantText(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "session-end", + "data": map[string]interface{}{ + "assistant_text": "I fixed the bug in main.go", + "reason": "user-requested", + }, + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, hooks.EventSessionEnd, event.Name) + assert.Equal(t, "I fixed the bug in main.go", event.Data.AssistantText) +} + +func TestOpenCodeDialect_Heartbeat(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "model-end", + "_scion_heartbeat": true, + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, hooks.EventModelEnd, event.Name) + assert.NotNil(t, event.Data.Raw) + isHB, ok := event.Data.Raw["_scion_heartbeat"].(bool) + assert.True(t, ok) + assert.True(t, isHB) +} + +func TestOpenCodeDialect_ActivityControlEvent(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "_activity", + "message": "thinking hard", + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, "", event.Name) + assert.Equal(t, "opencode", event.Dialect) +} + +func TestOpenCodeDialect_HookEventNameFallback(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "hook_event_name": "session-start", + "session_id": "xyz", + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, hooks.EventSessionStart, event.Name) +} + +func TestOpenCodeDialect_TokenExtraction(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "name": "model-end", + "data": map[string]interface{}{ + "input_tokens": float64(500), + "output_tokens": float64(150), + }, + } + + event, err := d.Parse(data) + require.NoError(t, err) + assert.Equal(t, int64(500), event.Data.InputTokens) + assert.Equal(t, int64(150), event.Data.OutputTokens) +} + +func TestOpenCodeDialect_MissingEventName(t *testing.T) { + d := NewOpenCodeDialect() + + data := map[string]interface{}{ + "tool_name": "Bash", + } + + _, err := d.Parse(data) + assert.Error(t, err) +} diff --git a/pkg/sciontool/hooks/dialects/registry.go b/pkg/sciontool/hooks/dialects/registry.go index 221e6044a..405c2b8a8 100644 --- a/pkg/sciontool/hooks/dialects/registry.go +++ b/pkg/sciontool/hooks/dialects/registry.go @@ -12,4 +12,5 @@ func RegisterBuiltins(processor *hooks.HarnessProcessor) { processor.RegisterDialect(NewClaudeDialect()) processor.RegisterDialect(NewGeminiDialect()) processor.RegisterDialect(NewCodexDialect()) + processor.RegisterDialect(NewOpenCodeDialect()) } diff --git a/pkg/sciontool/hooks/handlers/hub.go b/pkg/sciontool/hooks/handlers/hub.go index 6cdc87aa5..532a1d743 100644 --- a/pkg/sciontool/hooks/handlers/hub.go +++ b/pkg/sciontool/hooks/handlers/hub.go @@ -208,7 +208,24 @@ func (h *HubHandler) Handle(event *hooks.Event) error { }) case hooks.EventSessionEnd: - // Session ended + if event.Data.AssistantText != "" { + text := event.Data.AssistantText + const maxAssistantTextBytes = 64 * 1024 + if len(text) > maxAssistantTextBytes { + text = text[:maxAssistantTextBytes] + "\n[truncated]" + } + metadata := map[string]string{"source": "hook"} + msgCtx, msgCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer msgCancel() + if msgErr := h.client.SendOutboundMessage(msgCtx, hub.OutboundMessage{ + Msg: text, + Type: "assistant-reply", + Visibility: "verbose", + Metadata: metadata, + }); msgErr != nil { + log.Error("Hub: outbound assistant reply failed (session-end): %v", msgErr) + } + } log.Debug("Hub: Reporting stopped (session end)") as := state.AgentState{Phase: state.PhaseStopped} err = h.client.UpdateStatus(ctx, hub.StatusUpdate{ diff --git a/pkg/sciontool/hooks/handlers/hub_test.go b/pkg/sciontool/hooks/handlers/hub_test.go index 817cd31a7..4c714c791 100644 --- a/pkg/sciontool/hooks/handlers/hub_test.go +++ b/pkg/sciontool/hooks/handlers/hub_test.go @@ -824,6 +824,122 @@ func TestHubHandler_AssistantTextVisibilityTagging(t *testing.T) { }) } +// TestHubHandler_SessionEndAssistantText tests that session-end events with +// assistant_text forward the text to the outbound-message endpoint. +func TestHubHandler_SessionEndAssistantText(t *testing.T) { + t.Run("forwards assistant text on session-end", func(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + var mu sync.Mutex + var outboundMsg string + var outboundType string + statusCalls := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + + if msg, ok := payload["msg"].(string); ok { + outboundMsg = msg + outboundType, _ = payload["type"].(string) + } else { + statusCalls++ + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + scrubHubEnv(t) + t.Setenv("SCION_HUB_ENDPOINT", server.URL) + t.Setenv("SCION_AUTH_TOKEN", "test-token") + t.Setenv("SCION_AGENT_ID", "test-agent-id") + + handler := NewHubHandler() + if handler == nil { + t.Fatal("Expected handler to be created") + } + + err := handler.Handle(&hooks.Event{ + Name: hooks.EventSessionEnd, + Data: hooks.EventData{AssistantText: "Final answer from session"}, + }) + if err != nil { + t.Fatalf("Handle returned error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if outboundMsg != "Final answer from session" { + t.Errorf("Expected outbound msg %q, got %q", "Final answer from session", outboundMsg) + } + if outboundType != "assistant-reply" { + t.Errorf("Expected outbound type %q, got %q", "assistant-reply", outboundType) + } + if statusCalls != 1 { + t.Errorf("Expected 1 status call (stopped), got %d", statusCalls) + } + }) + + t.Run("no outbound message when session-end has no assistant text", func(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + var mu sync.Mutex + outboundCalls := 0 + statusCalls := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + + if _, ok := payload["msg"]; ok { + outboundCalls++ + } else { + statusCalls++ + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + scrubHubEnv(t) + t.Setenv("SCION_HUB_ENDPOINT", server.URL) + t.Setenv("SCION_AUTH_TOKEN", "test-token") + t.Setenv("SCION_AGENT_ID", "test-agent-id") + + handler := NewHubHandler() + if handler == nil { + t.Fatal("Expected handler to be created") + } + + err := handler.Handle(&hooks.Event{ + Name: hooks.EventSessionEnd, + }) + if err != nil { + t.Fatalf("Handle returned error: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if outboundCalls != 0 { + t.Errorf("Expected 0 outbound calls, got %d", outboundCalls) + } + if statusCalls != 1 { + t.Errorf("Expected 1 status call, got %d", statusCalls) + } + }) +} + // TestTruncateMessage tests the truncation helper function. func TestTruncateMessage(t *testing.T) { tests := []struct { diff --git a/pkg/sciontool/hooks/handlers/integration_test.go b/pkg/sciontool/hooks/handlers/integration_test.go new file mode 100644 index 000000000..4a7c3a6d4 --- /dev/null +++ b/pkg/sciontool/hooks/handlers/integration_test.go @@ -0,0 +1,289 @@ +/* +Copyright 2025 The Scion Authors. +*/ + +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/GoogleCloudPlatform/scion/pkg/sciontool/hooks" + "github.com/GoogleCloudPlatform/scion/pkg/sciontool/hooks/dialects" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOpenCodeFullEventPipeline(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + statusPath := filepath.Join(tmpHome, "agent-info.json") + statusHandler := &StatusHandler{StatusPath: statusPath} + loggingHandler := NewLoggingHandler() + + var mu sync.Mutex + var statusPayloads []map[string]interface{} + var outboundPayloads []map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + + if _, ok := payload["msg"]; ok { + outboundPayloads = append(outboundPayloads, payload) + } else { + statusPayloads = append(statusPayloads, payload) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + scrubHubEnv(t) + t.Setenv("SCION_HUB_ENDPOINT", server.URL) + t.Setenv("SCION_AUTH_TOKEN", "test-token") + t.Setenv("SCION_AGENT_ID", "test-agent-id") + + hubHandler := NewHubHandler() + require.NotNil(t, hubHandler) + + d := dialects.NewOpenCodeDialect() + + events := []map[string]interface{}{ + {"name": "session-start", "data": map[string]interface{}{"session_id": "s1"}}, + {"name": "prompt-submit", "data": map[string]interface{}{"prompt": "Fix the bug"}}, + {"name": "model-start", "data": map[string]interface{}{}}, + {"name": "tool-start", "data": map[string]interface{}{"tool_name": "Bash"}}, + {"name": "tool-end", "data": map[string]interface{}{"tool_name": "Bash", "success": true}}, + {"name": "model-end", "data": map[string]interface{}{}}, + {"name": "session-end", "data": map[string]interface{}{"assistant_text": "Bug fixed"}}, + } + + for _, raw := range events { + event, err := d.Parse(raw) + require.NoError(t, err) + + err = statusHandler.Handle(event) + require.NoError(t, err) + + err = loggingHandler.Handle(event) + require.NoError(t, err) + + err = hubHandler.Handle(event) + require.NoError(t, err) + } + + info := readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "stopped", info["phase"]) + + mu.Lock() + defer mu.Unlock() + + assert.GreaterOrEqual(t, len(statusPayloads), 1) + assert.Equal(t, 1, len(outboundPayloads)) + if len(outboundPayloads) == 1 { + assert.Equal(t, "Bug fixed", outboundPayloads[0]["msg"]) + assert.Equal(t, "assistant-reply", outboundPayloads[0]["type"]) + } +} + +func TestOpenCodeStickyBreakdown(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + statusPath := filepath.Join(tmpHome, "agent-info.json") + statusHandler := &StatusHandler{StatusPath: statusPath} + + d := dialects.NewOpenCodeDialect() + + sessionStart, _ := d.Parse(map[string]interface{}{ + "name": "session-start", "data": map[string]interface{}{}, + }) + require.NoError(t, statusHandler.Handle(sessionStart)) + + promptSubmit, _ := d.Parse(map[string]interface{}{ + "name": "prompt-submit", "data": map[string]interface{}{"prompt": "Do something"}, + }) + require.NoError(t, statusHandler.Handle(promptSubmit)) + + responseComplete, _ := d.Parse(map[string]interface{}{ + "name": "response-complete", "data": map[string]interface{}{"message": "Done"}, + }) + require.NoError(t, statusHandler.Handle(responseComplete)) + + info := readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "completed", info["activity"]) + + modelEnd, _ := d.Parse(map[string]interface{}{ + "name": "model-end", "data": map[string]interface{}{}, + }) + require.NoError(t, statusHandler.Handle(modelEnd)) + + info = readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "completed", info["activity"]) + + newPrompt, _ := d.Parse(map[string]interface{}{ + "name": "prompt-submit", "data": map[string]interface{}{"prompt": "New task"}, + }) + require.NoError(t, statusHandler.Handle(newPrompt)) + + info = readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "thinking", info["activity"]) +} + +func TestOpenCodeStickyWithHub(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + statusPath := filepath.Join(tmpHome, "agent-info.json") + statusHandler := &StatusHandler{StatusPath: statusPath} + + var mu sync.Mutex + var statusPayloads []map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + + var payload map[string]interface{} + json.NewDecoder(r.Body).Decode(&payload) + + if _, ok := payload["msg"]; !ok { + statusPayloads = append(statusPayloads, payload) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + scrubHubEnv(t) + t.Setenv("SCION_HUB_ENDPOINT", server.URL) + t.Setenv("SCION_AUTH_TOKEN", "test-token") + t.Setenv("SCION_AGENT_ID", "test-agent-id") + + hubHandler := NewHubHandler() + require.NotNil(t, hubHandler) + + d := dialects.NewOpenCodeDialect() + + events := []map[string]interface{}{ + {"name": "session-start", "data": map[string]interface{}{}}, + {"name": "prompt-submit", "data": map[string]interface{}{"prompt": "Task 1"}}, + {"name": "response-complete", "data": map[string]interface{}{"message": "Done 1"}}, + } + + for _, raw := range events { + event, err := d.Parse(raw) + require.NoError(t, err) + require.NoError(t, statusHandler.Handle(event)) + require.NoError(t, hubHandler.Handle(event)) + } + + info := readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "completed", info["activity"]) + + mu.Lock() + beforeCount := len(statusPayloads) + mu.Unlock() + + staleEvents := []map[string]interface{}{ + {"name": "model-start", "data": map[string]interface{}{}}, + {"name": "model-end", "data": map[string]interface{}{}}, + {"name": "tool-start", "data": map[string]interface{}{"tool_name": "Bash"}}, + {"name": "tool-end", "data": map[string]interface{}{"tool_name": "Bash"}}, + } + + for _, raw := range staleEvents { + event, err := d.Parse(raw) + require.NoError(t, err) + require.NoError(t, statusHandler.Handle(event)) + require.NoError(t, hubHandler.Handle(event)) + } + + mu.Lock() + afterCount := len(statusPayloads) + mu.Unlock() + + assert.Equal(t, beforeCount, afterCount, "sticky completed should suppress hub updates from stale events") + + info = readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "completed", info["activity"]) + + newPrompt, _ := d.Parse(map[string]interface{}{ + "name": "prompt-submit", "data": map[string]interface{}{"prompt": "Task 2"}, + }) + require.NoError(t, statusHandler.Handle(newPrompt)) + require.NoError(t, hubHandler.Handle(newPrompt)) + + info = readIntegrationAgentInfoMap(t, statusPath) + assert.Equal(t, "thinking", info["activity"]) + + mu.Lock() + finalCount := len(statusPayloads) + mu.Unlock() + assert.Greater(t, finalCount, afterCount, "new prompt should clear sticky and send hub update") +} + +func readIntegrationAgentInfoMap(t *testing.T, path string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var info map[string]interface{} + require.NoError(t, json.Unmarshal(data, &info)) + return info +} + +func TestIsHeartbeatEvent(t *testing.T) { + tests := []struct { + name string + event *hooks.Event + expected bool + }{ + { + name: "nil raw", + event: &hooks.Event{Name: hooks.EventModelEnd}, + expected: false, + }, + { + name: "heartbeat true", + event: &hooks.Event{ + Name: hooks.EventModelEnd, + Data: hooks.EventData{Raw: map[string]interface{}{"_scion_heartbeat": true}}, + }, + expected: true, + }, + { + name: "heartbeat false", + event: &hooks.Event{ + Name: hooks.EventModelEnd, + Data: hooks.EventData{Raw: map[string]interface{}{"_scion_heartbeat": false}}, + }, + expected: false, + }, + { + name: "no heartbeat key", + event: &hooks.Event{ + Name: hooks.EventModelEnd, + Data: hooks.EventData{Raw: map[string]interface{}{"other": "value"}}, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isHeartbeatEvent(tt.event)) + }) + } +} diff --git a/pkg/sciontool/hooks/handlers/limits.go b/pkg/sciontool/hooks/handlers/limits.go index 85caf3076..10b890de9 100644 --- a/pkg/sciontool/hooks/handlers/limits.go +++ b/pkg/sciontool/hooks/handlers/limits.go @@ -89,6 +89,10 @@ func (h *LimitsHandler) Handle(event *hooks.Event) error { return nil } + if isHeartbeatEvent(event) { + return nil + } + switch event.Name { case hooks.EventAgentEnd: if h.maxTurns <= 0 { @@ -276,3 +280,11 @@ func ParseEnvInt(key string) int { } return n } + +func isHeartbeatEvent(event *hooks.Event) bool { + if event.Data.Raw == nil { + return false + } + isHB, _ := event.Data.Raw["_scion_heartbeat"].(bool) + return isHB +} diff --git a/pkg/sciontool/hooks/handlers/limits_test.go b/pkg/sciontool/hooks/handlers/limits_test.go index 10f523cc2..9e84f1494 100644 --- a/pkg/sciontool/hooks/handlers/limits_test.go +++ b/pkg/sciontool/hooks/handlers/limits_test.go @@ -409,6 +409,62 @@ func TestSignalLimitsExceeded_CreatesTriggerFile(t *testing.T) { assert.NoError(t, err, "trigger file should exist after signalLimitsExceeded") } +func TestLimitsHandler_SkipsHeartbeatEvents(t *testing.T) { + scrubHubEnv(t) + tmpDir := t.TempDir() + limitsPath := filepath.Join(tmpDir, "agent-limits.json") + + err := InitLimitsFile(limitsPath, 0, 10) + require.NoError(t, err) + + h := &LimitsHandler{ + maxTurns: 0, + maxModelCalls: 10, + limitsPath: limitsPath, + statusHandler: &StatusHandler{StatusPath: filepath.Join(tmpDir, "agent-info.json")}, + } + + for i := 0; i < 50; i++ { + err := h.Handle(&hooks.Event{ + Name: hooks.EventModelEnd, + Data: hooks.EventData{ + Raw: map[string]interface{}{"_scion_heartbeat": true}, + }, + }) + require.NoError(t, err) + } + + ls := readLimitsFile(t, limitsPath) + assert.Equal(t, 0, ls.ModelCallCount) +} + +func TestLimitsHandler_CountsNonHeartbeatModelEnd(t *testing.T) { + scrubHubEnv(t) + tmpDir := t.TempDir() + limitsPath := filepath.Join(tmpDir, "agent-limits.json") + + err := InitLimitsFile(limitsPath, 0, 10) + require.NoError(t, err) + + h := &LimitsHandler{ + maxTurns: 0, + maxModelCalls: 10, + limitsPath: limitsPath, + statusHandler: &StatusHandler{StatusPath: filepath.Join(tmpDir, "agent-info.json")}, + } + + err = h.Handle(&hooks.Event{ + Name: hooks.EventModelEnd, + Data: hooks.EventData{ + Raw: map[string]interface{}{}, + }, + }) + require.NoError(t, err) + + ls := readLimitsFile(t, limitsPath) + assert.Equal(t, 1, ls.ModelCallCount) +} + // readLimitsFile reads and parses an agent-limits.json file for test assertions. func readLimitsFile(t *testing.T, path string) LimitsState { t.Helper() diff --git a/pkg/store/models.go b/pkg/store/models.go index 2bc16040d..edafe6e49 100644 --- a/pkg/store/models.go +++ b/pkg/store/models.go @@ -135,6 +135,7 @@ type AgentAppliedConfig struct { Task string `json:"task,omitempty"` // Initial task/prompt for the agent Attach bool `json:"attach,omitempty"` // If true, signals interactive attach mode to the broker/harness Branch string `json:"branch,omitempty"` // Git branch name (defaults to agent slug if empty) + Source string `json:"source,omitempty"` // Git source commit-ish (branch, tag, or commit) Workspace string `json:"workspace,omitempty"` // Host path to mount as /workspace (overrides default project root) GitClone *api.GitCloneConfig `json:"gitClone,omitempty"` diff --git a/pkg/util/git.go b/pkg/util/git.go index ebbd83af7..d9b422b10 100644 --- a/pkg/util/git.go +++ b/pkg/util/git.go @@ -163,7 +163,8 @@ func IsIgnored(dir, path string) bool { } // CreateWorktree creates a new git worktree at the specified path with a new branch. -func CreateWorktree(path, branch string) error { +// When source is non-empty, the worktree is based on that commit-ish instead of HEAD. +func CreateWorktree(path, branch, source string) error { // Guard: refuse to create worktrees inside an agent container. // SCION_HOST_UID is set by the runtime when launching containers. // Creating worktrees inside containers produces path-identity mismatches @@ -183,15 +184,22 @@ func CreateWorktree(path, branch string) error { } root := filepath.Dir(commonDir) - // git worktree add --relative-paths -b + // git worktree add --relative-paths -b [] // We run from root to ensure --relative-paths are calculated from root - cmd := exec.Command("git", "worktree", "add", "--relative-paths", "-b", branch, path) + args := []string{"worktree", "add", "--relative-paths", "-b", branch} + if source != "" { + args = append(args, path, source) + } else { + args = append(args, path) + } + cmd := exec.Command("git", args...) cmd.Dir = root if output, err := cmd.CombinedOutput(); err != nil { outputStr := string(output) // If branch already exists, try to just add it if strings.Contains(outputStr, "already exists") { - cmd = exec.Command("git", "worktree", "add", "--relative-paths", path, branch) + fallbackArgs := []string{"worktree", "add", "--relative-paths", path, branch} + cmd = exec.Command("git", fallbackArgs...) cmd.Dir = root if output, err := cmd.CombinedOutput(); err != nil { outputStr = string(output) @@ -207,6 +215,32 @@ func CreateWorktree(path, branch string) error { return nil } +// DefaultBranch returns the default branch name for the repository at projectDir. +// It first tries to read refs/remotes/origin/HEAD, then falls back to the current +// HEAD branch, and finally returns "main" if both fail. +func DefaultBranch(projectDir string) string { + cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD") + if projectDir != "" { + cmd.Dir = projectDir + } + output, err := cmd.Output() + if err == nil { + ref := strings.TrimSpace(string(output)) + if parts := strings.Split(ref, "/"); len(parts) > 0 { + return parts[len(parts)-1] + } + } + // Fallback: current HEAD branch + cmd = exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + if projectDir != "" { + cmd.Dir = projectDir + } + if output, err := cmd.Output(); err == nil { + return strings.TrimSpace(string(output)) + } + return "main" +} + // RemoveWorktree removes a git worktree at the specified path. // // Instead of using "git worktree remove" (which does its own directory @@ -573,8 +607,10 @@ func CloneSharedWorkspace(workspacePath, cloneURL, branch, token string) error { } args = append(args, authURL, workspacePath) + gitEnv := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + cmd := exec.Command("git", args...) - cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + cmd.Env = gitEnv output, err := cmd.CombinedOutput() if err != nil && branch != "" && isRemoteBranchNotFound(string(output)) { @@ -584,7 +620,7 @@ func CloneSharedWorkspace(workspacePath, cloneURL, branch, token string) error { fallbackArgs := []string{"clone", authURL, workspacePath} fallbackCmd := exec.Command("git", fallbackArgs...) - fallbackCmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + fallbackCmd.Env = gitEnv output, err = fallbackCmd.CombinedOutput() if err != nil { sanitized := strings.TrimSpace(sanitizeGitOutput(string(output), token)) diff --git a/pkg/util/git_test.go b/pkg/util/git_test.go index 21afa5a10..580cf0875 100644 --- a/pkg/util/git_test.go +++ b/pkg/util/git_test.go @@ -112,7 +112,7 @@ func TestGitUtils(t *testing.T) { branchName := "test-branch" // Create - if err := CreateWorktree(worktreePath, branchName); err != nil { + if err := CreateWorktree(worktreePath, branchName, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } @@ -132,7 +132,7 @@ func TestGitUtils(t *testing.T) { // Test PruneWorktrees prunePath := filepath.Join(repoDir, "prune-test") pruneBranch := "prune-branch" - if err := CreateWorktree(prunePath, pruneBranch); err != nil { + if err := CreateWorktree(prunePath, pruneBranch, ""); err != nil { t.Fatalf("CreateWorktree for prune failed: %v", err) } // Manually remove directory to simulate stale worktree @@ -144,7 +144,7 @@ func TestGitUtils(t *testing.T) { t.Fatalf("PruneWorktrees failed: %v", err) } // Verify we can create it again (if prune failed, this might fail with 'already exists') - if err := CreateWorktree(prunePath, pruneBranch); err != nil { + if err := CreateWorktree(prunePath, pruneBranch, ""); err != nil { t.Errorf("Failed to recreate worktree after prune: %v", err) } // Clean up @@ -154,7 +154,7 @@ func TestGitUtils(t *testing.T) { t.Run("PruneWorktreesIn", func(t *testing.T) { prunePath := filepath.Join(repoDir, "prune-in-test") pruneBranch := "prune-in-branch" - if err := CreateWorktree(prunePath, pruneBranch); err != nil { + if err := CreateWorktree(prunePath, pruneBranch, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } // Manually remove directory to simulate stale worktree @@ -174,7 +174,7 @@ func TestGitUtils(t *testing.T) { // Verify we can create the worktree again (prune cleared the stale record) os.Chdir(prevWd) - if err := CreateWorktree(prunePath, pruneBranch); err != nil { + if err := CreateWorktree(prunePath, pruneBranch, ""); err != nil { t.Errorf("Failed to recreate worktree after PruneWorktreesIn: %v", err) } // Clean up @@ -185,7 +185,7 @@ func TestGitUtils(t *testing.T) { // Create a branch via worktree, then remove the worktree without deleting the branch wtPath := filepath.Join(repoDir, "branch-del-test") branch := "delete-me-branch" - if err := CreateWorktree(wtPath, branch); err != nil { + if err := CreateWorktree(wtPath, branch, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } if _, err := RemoveWorktree(wtPath, false); err != nil { @@ -217,7 +217,7 @@ func TestGitUtils(t *testing.T) { wtPath := filepath.Join(repoDir, "wt-find") branch := "find-branch" - if err := CreateWorktree(wtPath, branch); err != nil { + if err := CreateWorktree(wtPath, branch, ""); err != nil { t.Fatalf("setup failed: %v", err) } @@ -242,7 +242,7 @@ func TestGitUtils(t *testing.T) { wtPath := filepath.Join(repoDir, "wt-rm-branch") branch := "rm-branch-test" - if err := CreateWorktree(wtPath, branch); err != nil { + if err := CreateWorktree(wtPath, branch, ""); err != nil { t.Fatalf("CreateWorktree failed: %v", err) } @@ -326,7 +326,7 @@ func TestCreateWorktree_FromWorktreeSucceeds(t *testing.T) { // Create a worktree from the main repo wtPath := filepath.Join(mainRepo, "child-wt") - if err := CreateWorktree(wtPath, "child-branch"); err != nil { + if err := CreateWorktree(wtPath, "child-branch", ""); err != nil { t.Fatalf("failed to create initial worktree: %v", err) } @@ -336,7 +336,7 @@ func TestCreateWorktree_FromWorktreeSucceeds(t *testing.T) { t.Fatal(err) } siblingPath := filepath.Join(mainRepo, "sibling-wt") - if err := CreateWorktree(siblingPath, "sibling-branch"); err != nil { + if err := CreateWorktree(siblingPath, "sibling-branch", ""); err != nil { t.Fatalf("expected worktree creation from within a worktree to succeed, got: %v", err) } @@ -353,7 +353,7 @@ func TestCreateWorktree_RejectsInsideContainer(t *testing.T) { mainRepo := setupGitRepo(t) wtPath := filepath.Join(mainRepo, "container-wt") - err := CreateWorktree(wtPath, "container-branch") + err := CreateWorktree(wtPath, "container-branch", "") if err == nil { t.Fatal("expected error creating worktree inside container context") } diff --git a/pkg/util/main_test.go b/pkg/util/main_test.go new file mode 100644 index 000000000..3b65089e9 --- /dev/null +++ b/pkg/util/main_test.go @@ -0,0 +1,13 @@ +package util + +import ( + "os" + "testing" + + "github.com/GoogleCloudPlatform/scion/internal/testgit" +) + +func TestMain(m *testing.M) { + testgit.Setup() + os.Exit(m.Run()) +} diff --git a/pkg/wsprotocol/connection.go b/pkg/wsprotocol/connection.go index a3576a24d..96eca5c13 100644 --- a/pkg/wsprotocol/connection.go +++ b/pkg/wsprotocol/connection.go @@ -32,7 +32,7 @@ const ( DefaultPingInterval = 30 * time.Second DefaultPongWait = 60 * time.Second DefaultWriteWait = 10 * time.Second - DefaultMaxMessageSize = 64 * 1024 // 64KB + DefaultMaxMessageSize = 10 * 1024 * 1024 // 10MB ) // ConnectionConfig holds configuration for a WebSocket connection. diff --git a/scripts/local-scion-server.sh b/scripts/local-scion-server.sh new file mode 100755 index 000000000..6939331ee --- /dev/null +++ b/scripts/local-scion-server.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -eou pipefail + +# Kill any existing scion server processes +echo "Checking for existing scion server processes..." +existing=$(ps aux 2>/dev/null | grep -i "scion server" | grep -v grep | awk '{print $2}' || true) +if [ -n "$existing" ]; then + echo "Killing existing server processes: $existing" + echo "$existing" | xargs kill 2>/dev/null || true + sleep 2 +fi + +pushd $HOME/.scion + +# Start the server detached (nohup + disown = survives after script exits) +nohup scion server start \ + --host 0.0.0.0 \ + --enable-hub \ + --enable-web --web-port 9810 \ + --enable-runtime-broker \ + > /tmp/scion-server.log 2>&1 & +disown + +echo "Waiting for server to be ready on port 9810..." +MAX_ATTEMPTS=60 +for i in $(seq 1 $MAX_ATTEMPTS); do + if curl -s http://localhost:9810/healthz > /dev/null 2>&1; then + echo "Server is ready on port 9810 (attempt $i)" + popd + exit 0 + fi + if [ "$i" -eq "$MAX_ATTEMPTS" ]; then + echo "ERROR: Server failed to start after $MAX_ATTEMPTS attempts" + echo "Server logs:" + cat /tmp/scion-server.log + popd + exit 1 + fi + sleep 1 +done + +popd diff --git a/scripts/proxy-host-to-docker.sh b/scripts/proxy-host-to-docker.sh new file mode 100755 index 000000000..24bf4410d --- /dev/null +++ b/scripts/proxy-host-to-docker.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# 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. + +set -euo pipefail + +BOLD='\033[1m' +NC='\033[0m' +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' + +CONTAINER_NAME="scion-proxy-host" +IMAGE="alpine/socat" + +usage() { + echo "Usage: $0 [-l listen_addr] [-p port] [-t target_host:target_port] [-d] [-h]" + echo "" + echo "Forwards traffic from the Docker host network to a target host:port," + echo "allowing containers to reach services that are not directly accessible." + echo "" + echo "Arguments:" + echo " -t TARGET Target host:port to forward to (required)" + echo " -l LISTEN Listen address on Docker host (default: 0.0.0.0)" + echo " -p PORT Port to listen on (default: 8091)" + echo " -d Detached mode (run in background, default)" + echo " -i Interactive mode (attach to container logs)" + echo " -s Stop/remove existing proxy" + echo " -h Show this help" + echo "" + echo "Examples:" + echo " $0 -t 192.168.4.31:8091" + echo " $0 -t 192.168.4.31:8091 -l 0.0.0.0 -p 8091" + echo " $0 -s" + exit 0 +} + +TARGET="" +LISTEN="0.0.0.0" +PORT="8091" +MODE="detached" + +while getopts "t:l:p:dsih" opt; do + case $opt in + t) TARGET="$OPTARG" ;; + l) LISTEN="$OPTARG" ;; + p) PORT="$OPTARG" ;; + d) MODE="detached" ;; + i) MODE="interactive" ;; + s) MODE="stop" ;; + h) usage ;; + *) usage ;; + esac +done + +if [ "$MODE" = "stop" ]; then + echo -e "${CYAN}[proxy]${NC} Stopping proxy container..." + if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + docker stop "$CONTAINER_NAME" 2>/dev/null || true + docker rm "$CONTAINER_NAME" 2>/dev/null || true + echo -e "${GREEN}[proxy]${NC} Container ${BOLD}${CONTAINER_NAME}${NC} removed." + else + echo -e "${YELLOW}[proxy]${NC} No proxy container found." + fi + exit 0 +fi + +if [ -z "$TARGET" ]; then + echo -e "${RED}[proxy]${NC} Error: Target host:port is required (-t)." + echo "" + usage + exit 1 +fi + +# Validate target format +if ! echo "$TARGET" | grep -qE '^[0-9a-zA-Z._-]+:[0-9]+$'; then + echo -e "${RED}[proxy]${NC} Error: Invalid target format '${TARGET}'. Expected host:port." + exit 1 +fi + +TARGET_HOST="${TARGET%:*}" +TARGET_PORT="${TARGET##*:}" + +check_tool() { + if ! command -v "$1" &>/dev/null; then + echo -e "${RED}[proxy]${NC} Error: '$1' is not installed." + exit 1 + fi +} + +check_tool docker + +# Check if a proxy is already running +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${YELLOW}[proxy]${NC} A proxy is already running:" + echo -e " Container: ${BOLD}${CONTAINER_NAME}${NC}" + echo -e " Listening: ${BOLD}${LISTEN}:${PORT}${NC}" + echo -e " Target: ${BOLD}${TARGET}${NC}" + echo "" + echo -e " Stop it first with: $0 -s" + exit 0 +fi + +echo -e "${CYAN}[proxy]${NC} Starting proxy container..." +echo -e " Listen: ${BOLD}${LISTEN}:${PORT}${NC}" +echo -e " Target: ${BOLD}${TARGET}${NC}" +echo "" + +docker run --name "$CONTAINER_NAME" \ + --network host \ + -d \ + --restart unless-stopped \ + "$IMAGE" \ + "tcp-listen:${PORT},reuseaddr,fork" \ + "tcp-connect:${TARGET_HOST}:${TARGET_PORT}" + +# Wait for container to start +sleep 1 + +if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo -e "${GREEN}[proxy]${NC} Proxy is running. Containers can reach the target via:" + echo "" + + # Determine the Docker bridge gateway + DOCKER_GATEWAY=$(docker network inspect bridge -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null || echo "172.17.0.1") + + echo -e " ${BOLD}Docker Gateway IP:${NC} ${DOCKER_GATEWAY}" + echo -e " ${BOLD}Proxy Port:${NC} ${PORT}" + echo "" + echo -e " From inside a container, reach the target at:" + echo -e " ${GREEN}${DOCKER_GATEWAY}:${PORT}${NC}" + echo "" + echo -e " Logs: ${BOLD}docker logs -f ${CONTAINER_NAME}${NC}" + echo -e " Stop: ${BOLD}$0 -s${NC}" +else + echo -e "${RED}[proxy]${NC} Failed to start proxy container." + echo -e "${YELLOW}[proxy]${NC} Container logs:" + docker logs "$CONTAINER_NAME" 2>&1 || true + docker rm "$CONTAINER_NAME" 2>/dev/null || true + exit 1 +fi diff --git a/test/Makefile b/test/Makefile new file mode 100644 index 000000000..250b3b8f6 --- /dev/null +++ b/test/Makefile @@ -0,0 +1,4 @@ +FLAGS=-y -t tester --harness opencode --harness-auth none + +test.multi-agent-collaboration-tests: + scion start test-runner $(FLAGS) "You are the test-runner scion agent, do NOT delete yourself. Read .design/scion-localhost-workflows.md and test/multi-agent-collaboration-tests.md then run the tests. Do not touch the server, just run the tests." diff --git a/test/multi-agent-collaboration-tests.md b/test/multi-agent-collaboration-tests.md new file mode 100644 index 000000000..d90e08c9a --- /dev/null +++ b/test/multi-agent-collaboration-tests.md @@ -0,0 +1,145 @@ +# Multi-Agent Collaboration Tests + +## Purpose + +Test that scion can orchestrate multiple agents and subagents, while delegating and communicating correctly. +There are two ways this happens: + +1. orchestrator passes task when starting the agent +2. orchestrator starts an agent and sends task as message + +However, there is a known bug in the hub and messaging system. +When an agent immediately starts and then sends a message, +the recipient agent is not ready in time and the message is lost. + +From this, we can create three test cases: + +1. orchestrator passes task when starting the agent +2a. orchestrator starts an agent and sends task as message, without extra instructions +2b. orchestrator is given extra instructions to check if subagent is ready before sending message + +## Test Overview + +Your goal is to run and monitor testing across the three scenarios. +For each test case, you will start a new scion agent named `orchestrator`, which will then start another agent `code-reviewer`. +You should never interact with the agents directly, only start, monitor passively, and cleanup as needed. + +**IMPORTANT** Run each test case sequentially, we only have one GPU. + +``` +you (test-runner) +└── orchestrator + └── code-reviewer +``` + +For each of the three test cases, you start the orchestrator with the `scion start` command: + +```bash +scion start orchestrator -y -t default --harness opencode --harness-auth none "${ORCHESTRATOR_INSTRUCTIONS}" +``` + + +Now that the `orchestrator` has started, your job is to monitor the progress: + +1. Did the `orchestrator` follow instructions for the test case and perform the steps in order? +2. Did the `code-reviewer` get the task by the method for the test case and follow its instructions? +3. Did the test case proceed as expected? Was the correct pattern used? +4. Did the agents set their status correctly as the progressed through processing? +5. Where there any hiccups and recoveries in the test case? + +Do this for each test case in order. Once all three are done, write a final report to the user. + +**Only report results as a message to the user.** You are running tests on behalf of a user who wants to know the summary of _what_ happened, _not_. Do not explore why, do not try to debug, do not try to work around system failures. Your only job is to run the tests and report the results. + +We expect test case (1) and (2b) to pass without issue. Test case (2a) should fail if the `orchrestrator` follows instructions and does not try to recover. You should wait long enough that the system notifies the `orchestrator` that it's `code-reviewer` agent has stalled, at which point it may try to resend the message. + + + +### Instructions for `orchestrator` + +The `orchestrator` needs slightly different instructions for each test case, +such that it performs the correct sequence of steps required by the test case. +The template instructions are: + +``` +You are the `orchestrator` agent. Your job is to coordinate a code review process. + +Follow this sequence of steps to start a `code-reviewer` agent + +<> + +Once your message has been sent, follow this sequence of steps to finalize the review process: + +1. Set blocked status: `sciontool status blocked "Waiting for code-reviewer analysis"` + - **CRITICAL: Immediately end your turn after setting blocked. Do NOT execute any further steps, do NOT poll for messages, do NOT continue working.** The blocked status is your signal that you are idle and waiting for a harness notification. Any work you do after setting blocked is wasted — you will not see the notification until your next turn. +2. Once you receive the report, create `/workspace/review-summary.md` containing: + - A header section + - code-reviewer's full report (copy their findings) + - Your own brief assessment section at the end evaluating whether the functions are production-ready +3. Mark yourself complete: `sciontool status task_completed "Multi-agent code review complete"` + + +The task for the code-reviewer is: + + +``` + +### Test Case 1: orchestrator passes task when starting the agent + +1. Start a code-reviewer agent with the full task embedded in its initial message: + `scion start code-reviewer -y -t default --harness opencode --harness-auth none "${TASK}"` + +### Test Case 2a: orchestrator starts an agent and sends task as message, without extra instructions + +1. Start a `code-reviewer` agent (no task, just boot it): `scion start code-reviewer -y -t default --harness opencode --harness-auth none` +2. Send the task as a message to the agent `scion message code-reviewer "${TASK}"`. Do not monitor, proceed to your blocked state. + +### Test Case 2b: orchestrator is given extra instructions to check if subagent is ready before sending message + +1. Start a `code-reviewer` agent (no task, just boot it): `scion start code-reviewer -y -t default --harness opencode --harness-auth none` +2. Wait until code-reviewer's phase is `running` (poll with `scion list --format json`), THEN verify it is actually ready by running `scion look code-reviewer --full --plain` and confirming it shows the agent prompt (not still initializing/booting), then send it this task: +3. Send the task as a message to the agent `scion message code-reviewer "${TASK}"` + + +### Instructions for `code-reviewer` + +The `code-reviewer` subagent is the same in every case and should be given the following task by the `orchestrator`. + +``` +Analyze these three function specifications and provide a detailed review: + +FUNCTION 1: calculateSum(numbers) +- Description: Sums all numbers in a list +- Input: array of integers +- Output: integer sum +- Edge cases: empty list returns 0, negative numbers allowed + +FUNCTION 2: findMax(numbers) +- Description: Finds the maximum value in a list +- Input: array of integers +- Output: maximum integer +- Edge cases: empty list returns null, single element returns itself + +FUNCTION 3: reverseString(s) +- Description: Reverses a string +- Input: string +- Output: reversed string +- Edge cases: empty string returns empty, single char returns itself + +Provide: +(1) Time and space complexity for each (O notation) +(2) Whether each edge case is handled correctly and if any are missing +(3) One actionable improvement suggestion per function + +After completing your analysis, you MUST send your report back to me using the scion CLI. Run this exact command: + scion message orchestrator "YOUR COMPLETE REPORT HERE WITH ALL THREE SECTIONS" + - This is mandatory. Do not just print your report in your response. You must execute the `scion message` command to deliver it through the Hub. + - The command sends the message to the orchestrator agent by name. Use the exact format above with your full report as the message body. Send only a single message. +After sending the report, mark your task as complete: + sciontool status task_completed "Multi-agent code review complete" +- **CRITICAL: Immediately end your turn after setting complete. Do NOT execute any further steps, do NOT continue working.** Setting complete is your signal that you are done. +``` + +## Cleanup + +Always use `scion delete`, not `scion stop` — stop leaves stale state.