Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions Dockerfile.ubuntu-helix
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,55 @@ RUN chmod +x dist/cli.js && \
COPY --from=goose-build /goose-binary /usr/local/bin/goose
RUN chmod +x /usr/local/bin/goose

# opencode CLI (ACP agent, `opencode acp`).
#
# Downloaded from the upstream release rather than built: opencode ships a
# single self-contained binary per platform, and the release digests are
# published in the ACP registry manifest so we can verify what we install.
#
# The version is recorded at /opt/helix/opencode.version. settings-sync-daemon
# reads that file to decide whether an admin-pinned override (system settings →
# opencode_version) differs from what ships here; if it does, it downloads the
# pinned release into the shared cache below instead of using this binary.
# Keep OPENCODE_VERSION, OPENCODE_SHA256_* , sandbox-versions.txt and
# pkg/opencode.BakedVersion in sync.
ARG OPENCODE_VERSION=1.18.18
ARG OPENCODE_SHA256_AMD64=0cddc222418b8553669905a8980c0cda7088f00da24d83d6ac76b01c9fdb2aaf
ARG OPENCODE_SHA256_ARM64=dcb1b5ec5687b43f87749560021f9203f3809e0ce5ae44ff9be8ae17083fe4ba
RUN <<_INSTALL_OPENCODE
set -eu
case "${TARGETARCH}" in
amd64) OC_ASSET="opencode-linux-x64.tar.gz"; OC_SHA="${OPENCODE_SHA256_AMD64}" ;;
arm64) OC_ASSET="opencode-linux-arm64.tar.gz"; OC_SHA="${OPENCODE_SHA256_ARM64}" ;;
*) echo "unsupported TARGETARCH=${TARGETARCH} for opencode" >&2; exit 1 ;;
esac
echo "=== Installing opencode ${OPENCODE_VERSION} (${OC_ASSET}) ==="
curl -fsSL -o /tmp/opencode.tar.gz \
"https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${OC_ASSET}"
echo "${OC_SHA} /tmp/opencode.tar.gz" | sha256sum -c -
# --no-same-owner: the archive carries uid 1000, which is `retro` in this
# image. Without it the agent user would own its own runtime binary and could
# overwrite it — the thing autoupdate=false is there to prevent.
tar -xzf /tmp/opencode.tar.gz --no-same-owner -C /usr/local/bin opencode
rm -f /tmp/opencode.tar.gz
chown root:root /usr/local/bin/opencode
chmod 755 /usr/local/bin/opencode
mkdir -p /opt/helix
echo "${OPENCODE_VERSION}" > /opt/helix/opencode.version
# Shared cache for admin-pinned releases. Bind-mounted from the sandbox host
# so one download serves every container on that host; the empty directory
# here is the mountpoint and the fallback when no mount is present.
mkdir -p /opt/helix/agent-cache/opencode
chown -R retro:retro /opt/helix/agent-cache
# Keep the build-time smoke check out of the runtime user's home. Even
# `--version` initialises opencode's data directories; running it with the
# image's HOME would leave /home/retro/.local root-owned and prevent desktop
# startup from creating Zed's state symlink there.
OPENCODE_SMOKE_HOME="$(mktemp -d)"
HOME="${OPENCODE_SMOKE_HOME}" /usr/local/bin/opencode --version
rm -r "${OPENCODE_SMOKE_HOME}"
_INSTALL_OPENCODE

WORKDIR /

# Helix startup scripts
Expand Down
73 changes: 73 additions & 0 deletions api/cmd/settings-sync-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ type SettingsDaemon struct {
// Code agent configuration (from Helix API)
codeAgentConfig *CodeAgentConfig

// openCodeLastAttempt is when we last tried (and failed) to install an
// admin-pinned opencode release. Zero means "no failure pending".
openCodeLastAttempt time.Time

// Whether user has a Claude subscription available for credential sync
claudeSubscriptionAvailable bool
codexSubscriptionAvailable bool
Expand Down Expand Up @@ -85,6 +89,24 @@ type CodeAgentConfig struct {
GooseRecipes []GooseRecipe `json:"goose_recipes,omitempty"`
GooseRecipeRootDir string `json:"goose_recipe_root_dir,omitempty"`
GooseBakedRecipe *GooseBakedRecipe `json:"goose_baked_recipe,omitempty"`

// OpenCodeBinary, when set, pins an opencode release newer than the one
// baked into this image. Only populated when Runtime == "opencode" and an
// admin has set the override.
OpenCodeBinary *CodeAgentBinary `json:"opencode_binary,omitempty"`
}

// CodeAgentBinary is a pinned agent release resolved by the API, keyed by
// GOARCH because the API does not know which architecture this host runs.
type CodeAgentBinary struct {
Version string `json:"version"`
Artifacts map[string]CodeAgentBinaryArtifact `json:"artifacts"`
}

// CodeAgentBinaryArtifact is one platform's archive and its expected digest.
type CodeAgentBinaryArtifact struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
}

// GooseRecipe maps a slash-command name to a recipe YAML on disk (container path).
Expand Down Expand Up @@ -398,6 +420,57 @@ func (d *SettingsDaemon) generateAgentServerConfig() map[string]interface{} {
},
}

case "opencode":
// opencode: `opencode acp` as a custom agent_server. Unlike goose and
// qwen, the entire LLM configuration travels in one env var
// (OPENCODE_CONFIG_CONTENT) instead of a config file — opencode merges
// it over its own defaults, so there is nothing to write to disk and
// nothing to clean up between agent switches.
baseURL := d.rewriteLocalhostURL(d.codeAgentConfig.BaseURL)

// Resolve the binary first: with an admin-pinned version this may
// download and verify a release, and if that fails we must not emit an
// agent_servers entry at all. Returning nil leaves the session without
// an opencode agent and the daemon retries on the next poll, which is
// the same deferral the claude_code branch uses while it waits for
// credentials. Falling back to the bundled binary would silently
// undo the admin's rollout.
command, err := d.resolveOpenCodeCommand()
if err != nil {
log.Printf("ERROR: opencode agent server cannot be registered: %v", err)
return nil
}

configContent, err := marshalOpenCodeConfig(d.buildOpenCodeConfig(baseURL))
if err != nil {
log.Printf("ERROR: opencode agent server cannot be registered: %v", err)
return nil
}

env := map[string]interface{}{
"OPENCODE_CONFIG_CONTENT": configContent,
// Referenced as {env:HELIX_API_KEY} from the config above.
"HELIX_API_KEY": d.userAPIKey,
// Per-session config dir so opencode's plugin bootstrap cannot
// collide with anything else under ~/.config.
"XDG_CONFIG_HOME": OpenCodeConfigHome,
// Session state under the workspace so it survives a restart.
"XDG_DATA_HOME": OpenCodeDataHome,
}

log.Printf("Using opencode runtime: command=%s, model=%s, base_url=%s",
command, d.codeAgentConfig.Model, baseURL)

return map[string]interface{}{
"opencode": map[string]interface{}{
"name": "opencode",
"type": "custom",
"command": command,
"args": []string{"acp"},
"env": env,
},
}

default: // "zed_agent" or empty (default)
// Zed Agent: Uses Zed's built-in agent panel - no agent_servers needed
// The container env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) are set by wolf_executor
Expand Down
Loading