| type | subsystem | |||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| last-updated | 2026-09-07 | |||||||||||||||||||||||||||||
| updated-by | schedule-d7190410-34062354146 | |||||||||||||||||||||||||||||
| sources |
|
|||||||||||||||||||||||||||||
| summary | Tool installation, configuration assembly, credential management, cache strategy, and oMo opt-in |
The setup module (src/services/setup/) bootstraps the CI environment before agent execution can begin. It installs runtime dependencies, configures authentication, assembles the OpenCode configuration, and manages a tools cache to speed up subsequent runs.
The runSetup() orchestrator follows a mode-gated sequence. When no orchestration plugin is requested (the default), the setup path is minimal — OpenCode only. When enable-omo: true, Bun and oMo are installed alongside OpenCode. A third mutually-exclusive mode, enable-omo-slim: true, installs Bun and OMO Slim instead and pins orchestrator as the default agent; requesting both oMo and OMO Slim fails fast. The two plugin-enabled modes share the same "enabled" cache partition and bootstrap shape, so the description below contrasts the default OpenCode-only path against the plugin-enabled path.
-
Parse credentials — Validates
auth-jsoninput early to fail fast on bad credentials. The input is a JSON object mapping LLM provider names to their auth configs (e.g.,{"anthropic": {"apiKey": "..."}}). -
Resolve versions — Determines target versions for OpenCode and Systematic. The
latestkeyword for OpenCode triggers a GitHub Releases API lookup. oMo and Bun version resolution is skipped. -
Restore tools cache — Checks for a cached bundle of previously installed tools, keyed by version, OS, and mode. A cache hit skips download steps entirely. The disabled-mode cache excludes Bun and
~/.config/opencodepaths to prevent stale oMo config from being restored. -
Install OpenCode CLI — Downloads the platform-appropriate release binary, extracts it, verifies it (
--version), and registers it in the GitHub Actions tool cache. The supported matrix is Linux and macOS on x64 or arm64; anything else is rejected by name before a URL is built (see Platform Support). The download source depends on whether the target is a stock OpenCode release or a harness build — harness builds are fetched fromfro-bot/agentreleases and verified against a publishedSHA256SUMSmanifest. -
Build CI config — Assembles the
OPENCODE_CONFIG_CONTENTenvironment variable, which configures OpenCode for CI operation. This includes disabling auto-updates, injecting the Systematic plugin, and pinningdefault_agentto"build". -
Merge user config — Merges the CI config on top of any user-provided
opencode-configinput. Plugin arrays are deduplicated by package name prefix. In disabled mode,oh-my-openagententries are stripped from bothpluginand legacypluginskeys, and a warning names any rewritten fields. Legacyplugins(plural) keys are also stripped — OpenCode only acceptsplugin(singular). -
Install Systematic plugin — Runs the OpenCode CLI's plugin install before caching, gated on either a fresh OpenCode install or a tools-cache miss (see When the Plugin Install Runs). If the install times out or fails, setup continues but skips saving the tools cache so an incomplete install is not persisted.
-
Save tools cache — If the plugin install ran and succeeded, saves the installed binaries for future runs.
-
Configure authentication — Sets up
ghCLI auth, configures Git identity as{bot}[bot]for audit trails, and writes the ephemeralauth.jsonwith0o600permissions.
When a plugin orchestrator is enabled, the setup path adds Bun installation and plugin setup after the OpenCode CLI install:
-
Steps 1–4 match the default mode (credentials, versions, cache restore, OpenCode install).
-
Install Bun runtime — Required for running the oMo / OMO Slim installer via
bunx. If Bun installation fails, the plugin is skipped but execution continues. -
Disable oMo telemetry — Sets
OMO_SEND_ANONYMOUS_TELEMETRY=0andOMO_DISABLE_POSTHOG=1before any oMo code runs, including the installer itself. -
Write optional configs — If
systematic-configis provided, writes it before the installer runs. -
Install the plugin — Runs the oMo or OMO Slim installer via Bun. This is treated as a graceful-fail operation: if it fails, the agent runs without the plugin's agent workflows. The installer error is captured but doesn't abort the run. OMO Slim additionally validates its preset (
openaioropencode-go) against an allowlist before installing. -
Build CI config — Assembles
OPENCODE_CONFIG_CONTENT. For oMo it does not pindefault_agent— oMo-managed config selects Sisyphus as the default whenagentis unset; for OMO Slim it pinsdefault_agentto"orchestrator". -
Merge configs — Merges CI config on top of any existing
opencode.json(which the installer may have created). Plugin arrays are deduplicated. The active plugin's entries (oh-my-openagentoroh-my-opencode-slim) are preserved. -
Save tools cache — The enabled-mode cache includes Bun, the Bun package cache, and
~/.config/opencodepaths. -
Configure authentication — Same as the default mode.
Default versions are defined in packages/runtime/src/shared/constants.ts (shared across surfaces) and src/shared/constants.ts (action-specific overrides):
| Tool | Constant | Purpose |
|---|---|---|
| OpenCode CLI | DEFAULT_OPENCODE_VERSION |
The AI coding agent platform |
| Bun | DEFAULT_BUN_VERSION |
JavaScript runtime and workspace package manager |
| oMo | DEFAULT_OMO_VERSION |
Oh My OpenAgent workflow framework |
| Systematic | DEFAULT_SYSTEMATIC_VERSION |
OpenCode plugin for structured workflows |
These can be overridden per-run via action inputs (opencode-version, omo-version, systematic-version). Stock tool pins are updated via Renovate-managed PRs; the OpenCode harness default is advanced by the harness release sync PR after a harness build exists.
Bun plays a dual role: it is both the runtime that runs the oMo / OMO Slim installer in CI and the package manager for this project's own workspace. The repository migrated from pnpm to Bun, which moved workspace configuration into bunfig.toml, replaced pnpm install with bun install, and changed how cache keys and license attribution are derived. Because the project's tooling itself depends on Bun, the Bun version is pinned and is baked into the tools-cache key (see Tools Cache) so a Bun bump cleanly invalidates stale tooling.
The default DEFAULT_OPENCODE_VERSION is a harness build (currently 1.18.29+harness.88b6b5fb) rather than a plain upstream OpenCode release. See Harness Builds for what that means and how it changes the install path.
OpenCode is consumed in two forms. A stock version is a plain upstream release (for example 1.18.29) published by the anomalyco/opencode project. A harness version carries a harness.<sha> suffix (for example 1.18.29+harness.88b6b5fb) and is a fro-bot/agent release that bundles the upstream binary together with a curated set of upstream integration refs — stalled or closed OpenCode PRs — merged onto the base release. The carry set spans provider/model routing fixes, SQLite lock-timeout retries, SSE backlog bounding, several memory-leak and stability patches, a bound on the plugin npm install, and OpenAI-family prompt-cache and version-gate corrections; as the base advances up the 1.18.x line, superseded and low-value carries are retired so the set stays lean. The exact carry set is defined in the integrationRefs list of packages/harness/harness.config.json; the action defaults to a harness build so that the carried patches are always present, while still allowing a stock version to be requested explicitly via the opencode-version input.
A harness build has one identity but two written forms, and understanding why they differ explains most of the surrounding machinery.
The build-metadata form — 1.18.29+harness.88b6b5fb — is what the binary self-reports and what the version pin in packages/runtime/src/shared/constants.ts records. The + segment is SemVer build metadata (§10), which is deliberately excluded from version precedence.
The prerelease form — 1.18.29-harness.88b6b5fb — is the published GitHub release tag, the npm package version, and the tool-cache key.
The prerelease form is not cosmetic. Harness releases live in the same tag namespace as the action's own v0.x product releases, and that namespace has two adversarial readers. Semantic-release scans tags matching ^v(.+) to compute the next product version, so harness tags dropped the v prefix in mid-2026 to stay invisible to it. But bare-semver tags with build metadata created a second problem: because SemVer strips build metadata for precedence, Renovate's github-tags datasource — which discovers candidates from git tags, not release objects — read 1.18.21+harness.22dee0ee as a stable 1.18.21 that outranked the real v0.x action line, quietly breaking grouped update branches in consuming repositories. Marking the GitHub release object as a prerelease does not help, because candidate discovery never looks at release objects.
Moving the tag to a genuine SemVer prerelease identifier fixes it at the level of the spec rather than at the level of a tool's enrichment behavior: prereleases are excluded by Renovate's default ignoreUnstable setting. The two constraints are therefore satisfied by different properties of the same tag — the missing v hides it from semantic-release, and the prerelease identifier hides it from Renovate. The eighteen legacy +harness. releases were migrated by duplication rather than rename (scripts/harness/duplicate-harness-release-tags.ts, a completed one-off), because their asset URLs are load-bearing for any pinned run still referencing them.
src/services/setup/opencode.ts recognizes either spelling as a harness build, and converts to the release-tag form at the boundaries that need it (toHarnessReleaseTag(); toolCacheVersion() is a named alias for the same conversion). Three behaviors follow:
-
Download source — Harness versions are routed to the
fro-bot/agentreleases URL instead of the upstreamanomalyco/opencodereleases, with the tag derived through the prerelease conversion. Percent-encoding of+as%2Bis retained for the migrated legacy tags, since GitHub stores tags URL-encoded and a raw+is misread as a space. Stock versions keep their conventionalv-prefixed upstream URL. -
Checksum verification — Every harness archive is verified against a
SHA256SUMSmanifest published alongside the binary in the same release. Stock downloads have no such manifest and are not checksum-verified by the action. Before any URL is constructed, the version string is validated against a strict semver-ish pattern as a defense-in-depth guard against path traversal or shell metacharacters. A harness pin that fails to download or verify is fail-closed — the run aborts rather than silently substituting a stock binary; the stock fallback (FALLBACK_VERSION, currently1.18.29) is reached only on thelatest-resolution path. -
Tool-cache identity —
@actions/tool-cacheruns versions throughsemver.clean()internally, which strips+harness.<sha>build metadata and would collapse a harness build onto a stock cache entry of the same base version. The prerelease form survivessemver.clean()intact, so using it as the cache key guarantees a harness build and a stock build of the same base version never share a cache slot. Logs and the binary's own--versionoutput keep the build-metadata form.
If the latest resolution path needs a fallback, the setup module falls back to a known-good stock version (FALLBACK_VERSION, currently 1.18.29) rather than a harness build. An explicitly-pinned harness build does not fall back — a failed download or checksum mismatch fails the run.
Because the tag shape is now load-bearing for two external tools and is derived in more than one place — the release workflow, the npm version builder, and the setup module — the test suite carries drift guards that read the repository's source text and fail if one producer is changed without the others. These guards exist because an earlier mirrored copy of the harness-version predicate asserted the opposite of production behavior and still passed, testing its own copy rather than the module.
The action runs on Linux and macOS, on x64 or arm64 — four combinations, and no others. getPlatformInfo() in src/services/setup/opencode.ts now asserts that matrix up front and throws a message naming the offending platform/arch pair, rather than mapping unknown values onto plausible-looking defaults.
The earlier shape used lookup tables with fallbacks: an unrecognized platform silently became linux, an unrecognized architecture silently became x64. That was survivable while the default OpenCode version was a stock upstream release, because upstream publishes a broader asset matrix. It stopped being survivable once the default became a harness build, which publishes only linux/darwin × x64/arm64 (packages/harness/src/platform.ts). On Windows the fallback produced a request for an asset that is never published, and the run failed several layers down with a 404 that said nothing about the platform. On an unsupported architecture the failure was worse than opaque — the arch fallback resolved to the x64 asset, so the download succeeded and the binary failed to execute later, at a point far removed from the cause.
Gating both halves in one place means the error names the real problem at the moment it is knowable. It also removed the Windows-shaped branches that existed downstream: archive-extension selection no longer special-cases .zip for Windows, and download validation no longer skips the file(1) check on a platform that can no longer be reached.
Two different strings describe an OpenCode install, and conflating them caused a cluster of EACCES failures worth understanding as one story.
installOpenCode() returns a directory — that is what @actions/tool-cache's cacheDir/find return, and it is what core.addPath() expects to receive. But several consumers need an executable path to hand to a child process: the Systematic plugin install spawns OpenCode directly, @fro.bot/harness's resolveBinary() reads OPENCODE_PATH as a binary, and every child that inherits the environment sees OPENCODE_PATH too (the deny-by-default env filter lets the OPENCODE_ prefix through). Passing the directory to any of those spawns it, which fails with EACCES because a directory is not executable.
The fix is a single helper, opencodeBinaryPath(), that owns the on-disk layout and is the only place allowed to turn an install directory into an executable path. SetupResult now carries both values as separately documented fields — opencodePath (the directory, consumed by addPath and reported as the opencode-path output) and opencodeBinaryPath (the executable, used by anything that spawns). ensureOpenCodeAvailable() in packages/runtime/src/agent/server.ts deliberately takes different halves for different purposes and logs both, on the reasoning that the entire failure mode was "which of these two strings got exported", so a recurrence should be diagnosable from the run log alone.
The Systematic plugin install is gated on either a fresh OpenCode install or a tools-cache miss. The two conditions are independent, not nested, and the earlier code that checked only the tools-cache flag left two real gaps: a tools-cache hit whose binary had gone missing falls through to a fresh OpenCode install that still needs its plugin installed and its cache re-saved; and installOpenCode() reports a cache hit from the runner's own tool cache alone, so a tools-cache key miss — from a bumped Systematic version, or an earlier invocation that skipped its save — must still install and save even on a warm tool cache.
When the install fails, two diagnostics now accompany the warning. If the parent environment carries NPM_CONFIG_* variables, the warning names those keys (names only, never values) and notes that the install child deliberately does not inherit them — the scrub exists so a network-fetched package's lifecycle scripts cannot see registry or proxy overrides, and naming the likely cause is preferable to either silently failing or proposing an allowlist that would defeat the scrub. Separately, when the failure is a spawn error (ENOENT or EACCES), diagnoseBinaryPath() inspects the path and reports what it actually found: absent, a dangling symlink, a directory rather than an executable, present but not executable by the current user, or a parent directory listing when the path itself is gone. That routine runs inside a catch block, so every filesystem call it makes is individually guarded — a failed diagnosis degrades to a short fallback string rather than masking the original error.
The CI config built by buildCIConfig() ensures OpenCode operates correctly in a headless CI environment:
- Auto-update disabled — Prevents OpenCode from trying to update itself mid-run.
- Systematic plugin injected — Ensures
@fro.bot/systematic@{version}is registered as an OpenCode plugin. The version is pinned to prevent drift. - Permission defaults hardened — The config bakes in deny rules so the run never stalls on an interactive permission prompt it cannot answer. The
doom_loopnative ask defaults todeny, secret-shaped file reads (*.env,*.env.*) are denied while*.env.examplestays readable, and edits are scoped to the workspace and any designated external directory. These defaults pair with the runtime's ask-answering behavior described in [[Execution Lifecycle]]: an ask that still reaches the agent is denied and logged rather than left to block until the execution deadline.
The final config is the result of merging:
- In default mode: CI config (with
default_agent: "build") + user-providedopencode-configinput. Existing localopencode.jsonfiles are ignored to prevent a stale orchestration config from leaking in. - In plugin-enabled mode: CI config (with
default_agentpinned to"orchestrator"for OMO Slim, or left unpinned so oMo selects Sisyphus) + existingopencode.json(from the installer) + user-providedopencode-configinput.
User values win on conflicts. In default mode, oh-my-openagent and oh-my-opencode-slim plugin entries in user config are stripped with a warning.
The setup module maintains its own cache (separate from the session cache) for installed binaries. The key is mode-partitioned: disabled mode omits the oMo version and restricts cached paths to OpenCode tooling only, preventing stale oMo config from being restored, while enabled mode additionally caches the Bun binary, the Bun package cache, and the oMo config directory.
Disabled-mode key:
opencode-tools-v2-{os}-disabled-oc-{opencodeVersion}-sys-{systematicVersion}-bun-{bunVersion}
Enabled-mode key:
opencode-tools-v2-{os}-enabled-oc-{opencodeVersion}-omo-{omoVersion}-sys-{systematicVersion}-bun-{bunVersion}
The Bun version is part of both keys even in disabled mode. The project's own tooling runs on Bun, so a Bun bump must invalidate the aggregate tools cache to avoid restoring a stale runtime; baking the Bun version into the key makes that automatic.
On a cache hit, the module verifies the binary is actually present in the tool cache before trusting it — cache hits where the binary is missing fall through to a fresh install. The lookup uses the tool-cache-safe form of the version (see Harness Builds), so a harness build never reuses a stock binary's cache entry. This cache typically saves 10-20 seconds per run.
A denied tools-cache write is now surfaced as a failure rather than swallowed. When the cache backend rejects a save — for example because the run holds a read-only cache token — the module reports it instead of pretending the save succeeded, so a silently non-persisting tools cache cannot masquerade as a healthy one. The same read-only-token discipline applies to the session cache; see [[Session Persistence]].
Credentials are handled with care:
auth.jsonis written with0o600permissions (owner-only read/write) and is never cached. It's regenerated fresh from secrets on every run.- Git identity is forced to
{bot}[bot]so commits made by the agent have a clear audit trail. - Telemetry is disabled for oMo before any oMo code executes.
The GitHub token the agent might use to post is provisioned conditionally, driven by the response-delivery decision computed in [[Execution Lifecycle|bootstrap]]. On comment and review flows (issue_comment, pull_request, issues) the credential is withheld — configureGhAuth() skips setting GH_TOKEN, skips writing the gh hosts.yml, and clears any ambient GH_CONFIG_DIR from earlier workflow steps. The rationale is that on those flows the action posts the agent's answer itself (the file-convention delivery path), so the model has no legitimate need to call gh — and a credential the model never receives is a credential a prompt-injected model cannot exfiltrate from disk or environment. A preflight check (git-credential-check.ts) additionally asserts that no persisted git credential is present in the effective git config for the workspace — local, global, system, worktree-scoped, or anything pulled in via include/includeIf — plus the origin remote URL, on these flows, which is why consumers are asked to check out with persist-credentials: false. Because the check reads global and system scope too, a credential planted anywhere on the runner (not just this checkout) is caught — but on a reused, non-ephemeral runner that also means a global/system config planted by an earlier job stays visible to every later job until the runner is recycled. The check has two narrow fail-open exceptions — no git binary, and a workspace that is not a git repository at all — and both are compatibility exemptions, not proof a credential is absent: they mean part of the check could not run, not that it ran and found nothing. Autonomous flows (schedule, workflow_dispatch) keep the credential provisioned, because they legitimately create branches, commits, and PRs on their own.
When a same-repo PR comment produces workspace edits, the action still needs a way to land them despite withholding the token — this is what the [[Execution Lifecycle|brokered push]] step provides. It commits on the model's behalf through the action's own Octokit client, so the write is gated by trusted event facts and a path allowlist (brokered-push-validation.ts) rather than by handing the agent a credential. Keeping persist-credentials: false remains essential: it ensures the withheld token has no residual copy in .git/config that a same-user shell inside the agent could read.
Independently of whether a credential is provisioned, the OpenCode child process is spawned under a deny-by-default environment filter (packages/runtime/src/agent/filter-env.ts, applied via with-scrubbed-env.ts). Only an enumerated allowlist of keys survives — GitHub Actions context, a handful of standard shell and locale variables, proxy/CA-bundle settings, and the OPENCODE_*, RUNNER_*, XDG_*, LC_*, and NODE_* prefixes. Anything ending in a credential-shaped suffix (_TOKEN, _API_KEY, _SECRET, _KEY, and similar), the AWS_* and INPUT_* prefixes, and GITHUB_TOKEN/GH_TOKEN by exact name are stripped even if they would otherwise match. The reduction is scoped: the harness restores its own environment immediately after the spawn so it can still reach the S3 backend, and it fails closed — if the scrub cannot complete, the child is never spawned.
The action accepts over 20 inputs defined in action.yaml, grouped into core, agent, S3, and configuration categories. The most important ones:
github-tokenandauth-jsonare required — they provide GitHub API access and LLM provider credentials respectively.trusted-head-shacarries a same-repository pull-request head SHA captured before agent execution (empty when unavailable). It is the trust anchor for the [[Execution Lifecycle|brokered push]] step: the harness diffs the workspace against this SHA and re-checks the live PR head against it immediately before committing, so a moved head aborts the push. Consumers wire it fromgithub.event.pull_request.head.sha(or the equivalent event field) in the workflow; it has no effect on flows that are not same-repo PR comments.promptprovides a custom instruction for the agent. Required forscheduleandworkflow_dispatchevents.output-modecontrols the delivery contract forscheduleandworkflow_dispatchruns (auto,working-dir,branch-pr; defaultauto). The compatibility valueautodeterministically resolves toworking-dir; usebranch-prexplicitly when branch/PR delivery is required. Theoutput-modeinput has no effect on non-manual event types (issue comments, PRs, etc.), which always returnnull. See Delivery-mode contract for manual workflow triggers for the historical design rationale.agentselects the OpenCode agent. When unset, uses OpenCode's built-inbuildagent. Must be a primary agent, not a subagent.review-skip-label(defaultskip-agent-review) names a PR label that suppresses the automatic review onpull_requestevents when present (case-insensitive). Setting it empty disables the opt-out. The label is a passive suppressor, not a hard block: an authorized@fro-botmention in the PR body still runs on opened/synchronize/reopened/edited actions, and an explicit review request naming the bot both admits the event and beats the label. The suppression is evaluated in the routing phase (see [[Execution Lifecycle]]).enable-omoenables Oh My OpenAgent (default:false). Whentrue, oMo installs and configures Sisyphus as the default agent.enable-omo-slimenables OMO Slim (default:false), mutually exclusive withenable-omo. Whentrue, OMO Slim installs with the chosenomo-slim-preset(openaioropencode-go, defaultopenai) and pinsorchestratoras the default agent.modeloverrides the LLM model inprovider/modelformat.timeoutcontrols the execution timeout (default: 30 minutes, 0 for no limit).s3-backup/s3-bucket/aws-region/s3-endpoint/s3-prefix/s3-expected-bucket-owner/s3-allow-insecure-endpoint/s3-sse-encryption/s3-sse-kms-key-idenable and configure the durable S3-compatible object store (see [[Session Persistence]]). Input validation rejects SSRF-vulnerable endpoints (metadata services, private IPs) and enforces HTTPS unless explicitly overridden.session-retentioncontrols how many sessions to keep before pruning (default: 50).dedup-windowconfigures the deduplication window in milliseconds (default: 10 minutes).
| Output | Description |
|---|---|
cache-status |
Cache restore status (hit, miss, corrupted) |
cache-save-result |
Cache save outcome (durable, store-only, skipped, declined-for-safety, not-persisted); set from the main step — the post-action retry reports only to the job summary |
See the full outputs table in the repository README.