[Show & Tell] Opencode Support and more#612
Conversation
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
we should use a proper jsonc parser package, not hand write this kind of thing
| 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(() => {}) | ||
| } | ||
| }, | ||
| ) |
There was a problem hiding this comment.
There are two issues with the current hook execution:
- Incorrect Timeout Unit: The
timeoutcommand expects the duration in seconds, butString(HOOK_TIMEOUT_MS)passes5000, which meanstimeoutwill wait for 5000 seconds (nearly 1.4 hours) instead of 5 seconds. - Unnecessary Temp File: Writing the payload to a temporary file and using
sh -cwithcatandrmis 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()| 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 | ||
| } |
There was a problem hiding this comment.
There is a mismatch between how _scion_heartbeat is sent by the plugin and how it is parsed by the dialect:
- In
scion-plugin.js,_scion_heartbeatis passed inside the nesteddataobject (which becomespayloadin the Go parser). - In
opencode.go, the parser checks the top-leveldatamap instead ofpayloadfor_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
}| if event.Data.AssistantText != "" { | ||
| text := event.Data.AssistantText | ||
| const maxAssistantTextBytes = 64 * 1024 | ||
| if len(text) > maxAssistantTextBytes { | ||
| text = text[:maxAssistantTextBytes] + "\n[truncated]" | ||
| } |
There was a problem hiding this comment.
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]"
}|
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. |
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
Bugfixes (?)