Skip to content

[Show & Tell] Opencode Support and more#612

Open
verdverm wants to merge 6 commits into
GoogleCloudPlatform:mainfrom
verdverm:opencode-support-july-26
Open

[Show & Tell] Opencode Support and more#612
verdverm wants to merge 6 commits into
GoogleCloudPlatform:mainfrom
verdverm:opencode-support-july-26

Conversation

@verdverm

@verdverm verdverm commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Opening this as a PR so it is easier to share and comment on lines in a conversation. I've been porting and updating my OpenCode support to be based on a current main (eg. script based harness creation)

Looking for your feedback on what looks like something I should break out and open as a smaller, more targeted PR.

Caveat, WIP by an agent and I have not dug deeply into the port yet.

Table of contents for the change groups:

Features

  1. Full OpenCode Harness Support (event bridge, hook dialect, config capabilities, heartbeat)
  2. OPENCODE_API_KEY auth support across the auth pipeline
  3. "none" auth type for harness authentication
  4. --source CLI flag for git source commit-ish for agent workspaces
  5. Default branch detection via git ls-remote --symref in the Hub
  6. manifest.json written to object storage during resource bootstrap
  7. Harness config sync status display with content hash comparison
  8. manifest.json upload to signed URL during harness-config sync
  9. --force-harness-configs server flag and RefreshDefaultTemplates
  10. disable_local_auth settings field for broker-like auth skipping
  11. Docker network mode configuration via settings.yaml
  12. scion exec command for in-container command execution
  13. WebSocket MaxMessageSize increase from 64KB to 10MB
  14. Hub session-end assistant_text forwarding as outbound message
  15. internal/testgit test helper with macOS keychain suppression
  16. opencode.jsonc support in config mapping and provisioning
  17. New scripts (buildkitd.toml, server startup, docker proxy, test specs)

Bugfixes (?)

  1. Restored missing StageCaptureAuthAssets call in provision.go
  2. Shebang portability fix in 3 image-build shell scripts
  3. Inline config Source field not propagated in CLI paths
  4. Redundant GIT_ASKPASS=echo env var removed
  5. AuthSelectedType fallback to Auth.DefaultType when empty
  6. Consolidated duplicate default-branch logic in agent creation
  7. Resource validation produces aggregate summaries instead of per-file noise
  8. Server startup no longer clobbers user harness-config customizations

verdverm added 5 commits July 4, 2026 05:53
The builtin harness removal (12077c5) accidentally deleted the
if err := harness.StageCaptureAuthAssets(...) line, leaving only a bare
fmt.Fprintf referencing an undefined err variable. This caused a go build
syntax error: non-declaration statement outside function body.

Fixes: 12077c5 (harness: remove dead builtin harness system)
Port opencode-support branch changes to current main-based codebase with
architecture adaptations for container-script harness model.

Phase 1 - Foundation:
- Add 'source' parameter to CreateWorktree, ProvisionAgent, GetAgent
- Add Source field to ScionConfig, StartOptions, AgentAppliedConfig,
  CreateAgentConfig, CreateAgentRequest (hub and hubclient)
- Add DisableLocalAuth setting for multi-tenant broker scenarios
- Thread source parameter through entire agent creation pipeline

Phase 2 - CLI and Auth:
- Add --source flag to create/start commands
- Add 'none' auth type for local LLMs (e.g., llama.cpp)
- Add DisableLocalAuth to settings schema
- Populate AuthSelectedType from harness config DefaultType

Phase 3 - Resolve:
- Add HarnessAuth to ResolveOptions for CLI auth precedence
- CLI --harness-auth flag now overrides all other auth sources

Phase 4 - OpenCode Harness:
- Update config.yaml capabilities: max_turns, max_model_calls,
  native_emitter, vertex_ai all 'yes' via plugin event bridge
- Add 'none' auth type support
- Add scion-plugin.js (482 lines) for event bridging
- Update provision.py for 'none' auth and plugin injection
- Modify ContainerScriptHarness.Provision() to stage plugin

Phase 5 - Sciontool:
- Add OpenCode dialect parser for pre-normalized events
- Add session-end assistant text forwarding in hub handler
- Add heartbeat filtering in limits handler
- Add integration tests for full event pipeline

Phase 6 - Hub:
- Add pkg/hub/default_branch.go for dynamic branch detection
- Replace hardcoded 'main' fallbacks with resolveDefaultBranch()
- Add debug logging to agent status updates and notifications

Phase 7 - Networking/WebSocket:
- Add docker.network config option for custom network modes
- Increase WebSocket max message size from 64KB to 10MB
- Preserve existing host networking behavior as default

Phase 8 - Exec/Infrastructure:
- Add 'scion exec' command for container command execution
- Add infrastructure scripts (local-scion-server, proxy-host-to-docker)
- Add test framework files and multi-agent collaboration tests
- Update shebangs to #!/usr/bin/env bash

Test Infrastructure:
- Add internal/testgit helper to prevent keychain prompts
- Add TestMain files to 7 packages for git env isolation

Files: 11 new, ~50 modified
Tests: Zero new regressions (all failures pre-existing)
Add OPENCODE_API_KEY environment variable to OpenCode harness auto-detection
and injection logic. The key is now discovered automatically alongside
ANTHROPIC_API_KEY and OPENAI_API_KEY.

Precedence: ANTHROPIC_API_KEY > OPENAI_API_KEY > OPENCODE_API_KEY > auth-file > vertex-ai

Changes:
- pkg/api/types.go: Add OpenCodeAPIKey field to AuthConfig
- pkg/harness/auth.go: Add OPENCODE_API_KEY lookup and env key registration
- pkg/harness/container_script_harness.go: Add OPENCODE_API_KEY passthrough
- pkg/harness/generic.go: Add OPENCODE_API_KEY to ResolveAuth
- pkg/agent/run.go: Add OPENCODE_API_KEY to isAuthEnvKey switch
- harnesses/opencode/config.yaml: Add to required_env and autodetect.env
- harnesses/opencode/provision.py: Add has_opencode detection and precedence
- Tests updated to verify new behavior
- Write manifest.json on both bootstrap and CLI sync paths so
  storage always contains a file inventory for every resource.
- Fix reconcile to keep manifest.json (it was deleting it as
  unknown).
- Repair missing manifest.json when server finds a resource
  whose content hash matches but the manifest is absent.
- Reword validation summary messages: group per-file issues
  into a single high-level report (storage-empty, missing_count,
  content_hash_mismatch) and drop the manifest.json check since
  manifest.json is now always present.
- Add harness-config list --hub storage validation so the sync
  status column reflects actual storage health, not just DB
  record comparison.
- Add --force-harness-configs server flag to preserve user
  customizations by default (matches existing --no-hub pattern).
- Support opencode.jsonc alongside opencode.json in the opencode
  provision script.
Decomposes the diff into 17 features and 8 bugfixes across categories including OpenCode harness support, auth improvements, CLI flags, storage changes, and infrastructure fixes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new opencode harness dialect and integration, including a JavaScript plugin to bridge OpenCode events to Scion's hook/status system. It also adds a new exec command to run commands inside agent containers, supports a --source flag for specifying workspace base commits, introduces a none authentication method, and adds Docker-specific network settings. Feedback on these changes highlights several critical issues: a regex bug in provision.py that can corrupt JSON strings containing comment-like sequences, a timeout unit mismatch and inefficient temp file usage in scion-plugin.js, a parsing mismatch for _scion_heartbeat in opencode.go, and unsafe byte-slicing of UTF-8 strings in hub.go that could lead to invalid characters.

Comment on lines +98 to +104
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current regular expression replacement in _strip_jsonc_comments will incorrectly strip comments inside JSON strings (such as URLs containing // or text containing /*). This will corrupt valid JSONC files and cause json.JSONDecodeError during parsing.

To fix this, use a regular expression that matches and preserves double-quoted strings while matching and removing comments.

Suggested change
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 _strip_jsonc_comments(text: str) -> str:
"""Strip // and /* */ comments from JSONC text for json.loads parsing."""
import re
pattern = r'("(?:\\.|[^"\\])*")|(/\*.*?\*/)|(//.*$)'
def replace(match):
if match.group(1):
return match.group(1)
return ""
return re.sub(pattern, replace, text, flags=re.MULTILINE | re.DOTALL)

@verdverm verdverm Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use a proper jsonc parser package, not hand write this kind of thing

Comment on lines +104 to +127
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(() => {})
}
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues with the current hook execution:

  1. Incorrect Timeout Unit: The timeout command expects the duration in seconds, but String(HOOK_TIMEOUT_MS) passes 5000, which means timeout will wait for 5000 seconds (nearly 1.4 hours) instead of 5 seconds.
  2. Unnecessary Temp File: Writing the payload to a temporary file and using sh -c with cat and rm is inefficient and can leave orphaned temp files if the process is terminated.

Instead, you can spawn the process directly and write the payload to its stdin stream. This is cleaner, more secure, and avoids disk I/O.

    const { spawn } = await import("child_process")
    const child = spawn("timeout", [String(HOOK_TIMEOUT_MS / 1000), "sciontool", "hook", "--dialect=opencode"], {
      stdio: ["pipe", "ignore", "ignore"]
    })
    child.on("error", (err) => {
      client.app.log({
        body: {
          service: "scion-plugin",
          level: "error",
          message: "scion hook failed: " + name,
          extra: { error: String(err)?.message || String(err), name: name },
        },
      }).catch(() => {})
    })
    child.stdin.on("error", () => {})
    child.stdin.write(payload)
    child.stdin.end()

Comment on lines +90 to +95
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a mismatch between how _scion_heartbeat is sent by the plugin and how it is parsed by the dialect:

  1. In scion-plugin.js, _scion_heartbeat is passed inside the nested data object (which becomes payload in the Go parser).
  2. In opencode.go, the parser checks the top-level data map instead of payload for _scion_heartbeat.

This causes heartbeats to never be detected in production, which means they won't be skipped by the limits handler. To fix this, check payload (or both data and payload) for _scion_heartbeat.

	isHB, _ := payload["_scion_heartbeat"].(bool)
	if !isHB {
		isHB, _ = data["_scion_heartbeat"].(bool)
	}
	if isHB {
		if event.Data.Raw == nil {
			event.Data.Raw = make(map[string]interface{})
		}
		event.Data.Raw["_scion_heartbeat"] = true
	}

Comment on lines +211 to +216
if event.Data.AssistantText != "" {
text := event.Data.AssistantText
const maxAssistantTextBytes = 64 * 1024
if len(text) > maxAssistantTextBytes {
text = text[:maxAssistantTextBytes] + "\n[truncated]"
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Slicing a Go string directly by bytes (e.g., text[:maxAssistantTextBytes]) can cut a multi-byte UTF-8 character in half, resulting in an invalid UTF-8 string. This can cause JSON marshalling errors or rendering issues downstream.

To prevent this, ensure the truncation boundary aligns with a valid UTF-8 rune boundary.

		if event.Data.AssistantText != "" {
			text := event.Data.AssistantText
			const maxAssistantTextBytes = 64 * 1024
			if len(text) > maxAssistantTextBytes {
				// Safely truncate to a valid UTF-8 boundary
				i := maxAssistantTextBytes
				for i > 0 && !utf8.ValidString(text[:i]) {
					i--
				}
				text = text[:i] + "\n[truncated]"
			}

@ptone

ptone commented Jul 6, 2026

Copy link
Copy Markdown
Member

So (obviously) super happy to have agent based contributions - if open code is your daily driver it can be a huge help just to exercise the usage. Fundamentally - as I hope you can see/understand is that scion defines an abstraction around an agent, and the harness-config system projects that into the details of a particular harness. The original implmentation a few months ago had all of this very tightly coupled, then the second iteration separated harness from template, but the harnesses were still hard-coded in the core packages - this is now a time to make it much easier to ship support and maintenance for harnesses outside of the core package.

#613 - which should hopefully be landing soon - will hopefully make the process of fully supporting opencode easier. See if you want to take a crack at having your agent rework some of these features to lean on the new common support file. Honestly what is a huge help is still what I find to be the bottleneck which is the type of QA testing (like harness auth logins) that are harder to fully automate.

Thanks for your involvement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants