Skip to main content

agent_profile/adapter/
opencode.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! OpenCode: `OPENCODE_CONFIG_DIR` per profile (SP2 design §8.2).
8
9use std::path::PathBuf;
10
11use super::{
12    Adapter, AdapterEvidence, AdapterMetadata, Capability, CapabilityClaim, CapabilityState,
13    EnvOverride, Mechanism, PathKind, PlanContext, PlannedLaunch, SupportLevel, profile_dir,
14};
15use crate::config::AppRoot;
16use crate::error::Result;
17use crate::name::ProfileName;
18
19const VAR: &str = "OPENCODE_CONFIG_DIR";
20
21static METADATA: AdapterMetadata = AdapterMetadata {
22    id: "opencode",
23    executable: "opencode",
24    mechanism: Mechanism::Env(VAR),
25    support: SupportLevel::Proven,
26    evidence: AdapterEvidence {
27        mechanism_id: "opencode-config-dir-v1",
28        verified_at: "2026-11-09",
29        upstream_version: "v2.2.6",
30        source_url: "https://opencode.ai/docs/cli/",
31        notes: "the config-side service.json followed OPENCODE_CONFIG_DIR into the target while \
32                session/state data (opencode.db with -shm/-wal, shell/, opencode.log) stayed at \
33                the default .local/share/opencode (§9 outcome 1, both repetitions); \
34                OPENCODE_CONFIG names a config file to READ and relocates none of the agent's \
35                writes, so the mechanism is the directory variable alone; the probe's launches run \
36                unauthenticated, so auth.json is never written and no credential file appears",
37    },
38    capabilities: &[
39        CapabilityClaim {
40            capability: Capability::ConfigIsolation,
41            state: CapabilityState::Supported,
42            basis: "measured: `+ f /home/agent/probe-target/service.json` — the config-side write \
43                    followed OPENCODE_CONFIG_DIR into the target, and the second delta \
44                    (`envfile:OPENCODE_CONFIG`) shows the same write at the default \
45                    `.config/opencode/service.json` when only the file variable is set",
46        },
47        CapabilityClaim {
48            capability: Capability::CredentialIsolation,
49            state: CapabilityState::Unknown,
50            basis: "unmeasured: `auth.json` was never observed anywhere in the deltas — \
51                    unauthenticated launches never write it — so where stored credentials live was \
52                    not established and §6's 'probe establishes where credentials are stored' \
53                    exception is not met; `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` (strings \
54                    measured) are the documented bypass variables, and no stored-credential \
55                    observation was made",
56        },
57        CapabilityClaim {
58            capability: Capability::StateIsolation,
59            state: CapabilityState::NotSupported,
60            basis: "measured: `+ f /home/agent/.local/share/opencode/opencode.db` (with `-shm`/\
61                    `-wal`), `+ d .../shell/...` and `opencode.log` stayed at \
62                    `.local/share/opencode` under both mechanisms: neither names a data directory \
63                    and the strings scan contains no `OPENCODE_DATA*`/`OPENCODE_HOME` token (Q2)",
64        },
65    ],
66    env: &[EnvOverride { name: VAR, sensitive: false }],
67    conflicts: &[],
68    credential_env: &["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
69};
70
71/// The OpenCode adapter.
72#[derive(Debug, Clone, Copy)]
73pub struct Opencode;
74
75impl Adapter for Opencode {
76    fn metadata(&self) -> &'static AdapterMetadata {
77        &METADATA
78    }
79
80    fn paths(&self, root: &AppRoot, profile: &ProfileName) -> Vec<(PathBuf, PathKind)> {
81        // `OPENCODE_CONFIG_DIR` names the configuration directory itself (the target received
82        // `service.json` directly at its top level), so the profile directory IS the config
83        // directory. Data stays where §8.2 and the measured delta recorded it — under
84        // `XDG_DATA_HOME`, default `~/.local/share/opencode` — and is deliberately not declared.
85        vec![(profile_dir(root, profile, METADATA.id), PathKind::Dir)]
86    }
87
88    fn plan(&self, ctx: &PlanContext<'_>) -> Result<PlannedLaunch> {
89        METADATA.mechanism.plan(self, ctx)
90    }
91}