@agent-controller/runtime-opencode 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/event-translator.d.ts +77 -0
- package/dist/event-translator.js +322 -0
- package/dist/honesty.d.ts +59 -0
- package/dist/honesty.js +226 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +704 -0
- package/dist/opencode-config.d.ts +165 -0
- package/dist/opencode-config.js +517 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +2 -0
- package/dist/wire.d.ts +5 -0
- package/dist/wire.js +8 -0
- package/package.json +50 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a CompiledSpec to opencode's `opencode.json` agent-config shape.
|
|
3
|
+
*
|
|
4
|
+
* Phase 2 slice 2.2 (this file): a pure function that builds the agent
|
|
5
|
+
* config opencode expects. No SDK invocation, no file I/O — the caller
|
|
6
|
+
* (slice 2.3+ will be runtime-opencode/src/index.ts after the SDK is
|
|
7
|
+
* wired in) is responsible for writing the JSON to disk and pointing
|
|
8
|
+
* opencode at it.
|
|
9
|
+
*
|
|
10
|
+
* Source of truth for the opencode config shape:
|
|
11
|
+
* https://opencode.ai/docs/config/
|
|
12
|
+
* https://opencode.ai/docs/agents/
|
|
13
|
+
*
|
|
14
|
+
* Key differences between this adapter's mapping and the Pi adapter:
|
|
15
|
+
*
|
|
16
|
+
* - Pi extension-format tools (anything declared in spec.tools[] with
|
|
17
|
+
* `entrypoint` but not `builtin`) are NOT translatable to opencode.
|
|
18
|
+
* Opencode loads its own native tools and MCP-discovered tools;
|
|
19
|
+
* it does not execute Pi-format extension JS modules. We REJECT
|
|
20
|
+
* specs that declare custom Pi-extension tools when targeting
|
|
21
|
+
* opencode. This matches the harness-matrix ❌ cell — "rejected at
|
|
22
|
+
* compile time with a field-naming error."
|
|
23
|
+
*
|
|
24
|
+
* - opencode's tool registry is `permissions: { <tool>: "allow"|"ask"|"deny" }`.
|
|
25
|
+
* The old top-level `tools: {...}` field is deprecated. We emit the
|
|
26
|
+
* new permission shape.
|
|
27
|
+
*
|
|
28
|
+
* - opencode's model id is `<provider>/<modelId>`. Pi's getModel takes
|
|
29
|
+
* (provider, name) separately. We concatenate.
|
|
30
|
+
*
|
|
31
|
+
* - opencode's `prompt` field accepts either an inline string or a
|
|
32
|
+
* path to a prompt file. Inline is simpler and avoids a transient
|
|
33
|
+
* file-write; we always inline. The honesty preamble + persona
|
|
34
|
+
* instructions + skill bodies (when slice 2.3 lands) are joined
|
|
35
|
+
* with double newlines into the single prompt string.
|
|
36
|
+
*
|
|
37
|
+
* What slice 2.2 does NOT yet do (deferred to 2.3+):
|
|
38
|
+
* - Skill body inlining (the v0.1.7 "active by default" lesson) —
|
|
39
|
+
* skills require fs reads and we keep this slice pure.
|
|
40
|
+
* - MCP server config — opencode has native MCP; mapping is small but
|
|
41
|
+
* belongs in slice 2.3.
|
|
42
|
+
* - Subagents — opencode has its own subagent format; slice 2.3.
|
|
43
|
+
* - Extensions with `source: npm:...` — handled by opencode's package
|
|
44
|
+
* install path or rejected; slice 2.3 design decision.
|
|
45
|
+
*/
|
|
46
|
+
import type { CompiledSpec } from "./types.js";
|
|
47
|
+
/**
|
|
48
|
+
* Pre-resolved skill body, ready to be inlined into the system prompt.
|
|
49
|
+
* The caller (index.ts) reads spec.skills[].entrypoint from disk, strips
|
|
50
|
+
* YAML frontmatter, and passes the resulting bodies in.
|
|
51
|
+
*
|
|
52
|
+
* Kept out of buildOpencodeConfig so that function remains testable without
|
|
53
|
+
* fs fixtures — same I/O separation Pi adapter uses.
|
|
54
|
+
*/
|
|
55
|
+
export interface SkillBody {
|
|
56
|
+
name: string;
|
|
57
|
+
body: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Pre-resolved subagent definition, ready to be added to opencode's
|
|
61
|
+
* `cfg.agent` map. The caller reads spec.subagents[].entrypoint (a .md file
|
|
62
|
+
* with YAML frontmatter), parses it, and passes the structured fields in.
|
|
63
|
+
*
|
|
64
|
+
* Field semantics mirror Pi's AgentConfig (see extensions/subagent/agents.ts):
|
|
65
|
+
* - name: agent identifier
|
|
66
|
+
* - description: when the model should invoke this subagent
|
|
67
|
+
* - tools: optional Pi built-in tool names the subagent may use; mapped to
|
|
68
|
+
* opencode permission keys the same way the primary agent's tools are
|
|
69
|
+
* - model: optional override of provider/name; falls back to primary model
|
|
70
|
+
* - systemPrompt: the subagent's system prompt body (markdown after the
|
|
71
|
+
* frontmatter)
|
|
72
|
+
*/
|
|
73
|
+
export interface SubagentDefinition {
|
|
74
|
+
name: string;
|
|
75
|
+
description: string;
|
|
76
|
+
tools?: string[];
|
|
77
|
+
model?: string;
|
|
78
|
+
systemPrompt: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The subset of opencode's agent config shape we currently target.
|
|
82
|
+
* Intentionally narrow — opencode supports many more knobs (temperature,
|
|
83
|
+
* mode, model rules, etc.) but we only emit what ADL drives today. Add
|
|
84
|
+
* fields here as the corresponding ADL surface is mapped.
|
|
85
|
+
*
|
|
86
|
+
* The string keys are opencode's; do NOT rename without checking the
|
|
87
|
+
* opencode docs and the SDK's TypeScript types (slice 2.3 will pull
|
|
88
|
+
* those in).
|
|
89
|
+
*/
|
|
90
|
+
export interface OpencodeAgentConfig {
|
|
91
|
+
description: string;
|
|
92
|
+
model: string;
|
|
93
|
+
prompt: string;
|
|
94
|
+
temperature?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Subagent mode: opencode treats agents with mode="subagent" as
|
|
97
|
+
* delegate-only — they can be invoked by the primary agent via the
|
|
98
|
+
* task tool but won't appear as a top-level chat interface. The
|
|
99
|
+
* primary agent (the one named in spec.metadata.name) omits this
|
|
100
|
+
* field so opencode treats it as mode="all" by default. Codex
|
|
101
|
+
* pass guidance: opencode supports "subagent" | "primary" | "all".
|
|
102
|
+
*/
|
|
103
|
+
mode?: "subagent" | "primary" | "all";
|
|
104
|
+
permission?: Record<string, "allow" | "ask" | "deny">;
|
|
105
|
+
/**
|
|
106
|
+
* Disable this agent entry entirely so opencode skips it. Used to
|
|
107
|
+
* mask out opencode's native agents (general/explore/plan/build)
|
|
108
|
+
* so they cannot be invoked via task tool. Codex pass 4 of slice 2.5.
|
|
109
|
+
*/
|
|
110
|
+
disable?: boolean;
|
|
111
|
+
}
|
|
112
|
+
/** opencode MCP server config — local stdio. */
|
|
113
|
+
export interface OpencodeMcpLocal {
|
|
114
|
+
type: "local";
|
|
115
|
+
command: string[];
|
|
116
|
+
environment?: Record<string, string>;
|
|
117
|
+
enabled?: boolean;
|
|
118
|
+
}
|
|
119
|
+
/** opencode MCP server config — remote HTTP/SSE. */
|
|
120
|
+
export interface OpencodeMcpRemote {
|
|
121
|
+
type: "remote";
|
|
122
|
+
url: string;
|
|
123
|
+
headers?: Record<string, string>;
|
|
124
|
+
enabled?: boolean;
|
|
125
|
+
}
|
|
126
|
+
export type OpencodeMcpConfig = OpencodeMcpLocal | OpencodeMcpRemote;
|
|
127
|
+
export interface OpencodeRootConfig {
|
|
128
|
+
$schema: string;
|
|
129
|
+
agent: Record<string, OpencodeAgentConfig>;
|
|
130
|
+
/**
|
|
131
|
+
* MCP (Model Context Protocol) servers. Each key is the server name and
|
|
132
|
+
* the value is a stdio or remote transport config. Tools the MCP server
|
|
133
|
+
* exposes appear in opencode under permission keys of the form
|
|
134
|
+
* `<server>_<tool>`; ADL's allowlist already denies "*" so these are
|
|
135
|
+
* inactive unless the spec explicitly grants `mcp_servers` access.
|
|
136
|
+
* Optional — omitted when spec.mcpServers is absent or empty.
|
|
137
|
+
*/
|
|
138
|
+
mcp?: Record<string, OpencodeMcpConfig>;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build an opencode root config from a CompiledSpec. Pure — does no
|
|
142
|
+
* file I/O and reads no env. Throws on inputs that cannot be safely
|
|
143
|
+
* mapped (e.g. custom Pi-extension tools targeting opencode), with an
|
|
144
|
+
* error message that names the unsupported field.
|
|
145
|
+
*
|
|
146
|
+
* `agentName` defaults to spec.metadata.name; callers can override
|
|
147
|
+
* (e.g. for hermetic test fixtures) when needed.
|
|
148
|
+
*/
|
|
149
|
+
export declare function buildOpencodeConfig(spec: CompiledSpec, options?: {
|
|
150
|
+
agentName?: string;
|
|
151
|
+
/**
|
|
152
|
+
* Pre-resolved skill bodies, in declaration order. Each body is
|
|
153
|
+
* appended to the system prompt wrapped via wrapSkillBody() so the
|
|
154
|
+
* model can see what skill it's reading.
|
|
155
|
+
*
|
|
156
|
+
* Caller (index.ts) is responsible for reading spec.skills[].entrypoint
|
|
157
|
+
* (SKILL.md files) and stripping YAML frontmatter before passing here.
|
|
158
|
+
*/
|
|
159
|
+
skillBodies?: SkillBody[];
|
|
160
|
+
/**
|
|
161
|
+
* Pre-resolved subagent definitions. Each becomes an entry in
|
|
162
|
+
* opencode's `cfg.agent` map with `mode: "subagent"`.
|
|
163
|
+
*/
|
|
164
|
+
subagentDefinitions?: SubagentDefinition[];
|
|
165
|
+
}): OpencodeRootConfig;
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { HONESTY_PREAMBLE, wrapSkillBody } from "./honesty.js";
|
|
2
|
+
/**
|
|
3
|
+
* Map Pi built-in tool names to the opencode permission key that
|
|
4
|
+
* controls them. The mapping is NOT identity:
|
|
5
|
+
*
|
|
6
|
+
* bash → bash (1:1)
|
|
7
|
+
* read → read (1:1)
|
|
8
|
+
* edit → edit (1:1, controls opencode's `edit` and `apply_patch`)
|
|
9
|
+
* write → edit (opencode does not expose a separate `write`
|
|
10
|
+
* permission; file creation/overwrite is also
|
|
11
|
+
* gated by `edit`. Codex pass 1 of slice 2.2
|
|
12
|
+
* caught the earlier 1:1 mapping, which would
|
|
13
|
+
* have left write-only specs unable to modify
|
|
14
|
+
* any files.)
|
|
15
|
+
*
|
|
16
|
+
* If a spec declares BOTH `edit` and `write` (a common combination),
|
|
17
|
+
* both map to "edit: allow" — the duplicate is idempotent and
|
|
18
|
+
* harmless. If opencode later adds a distinct `write` permission this
|
|
19
|
+
* table will need to revisit the mapping.
|
|
20
|
+
*
|
|
21
|
+
* Source: https://opencode.ai/docs/config/ — only read, edit, glob,
|
|
22
|
+
* grep, list, bash, task, external_directory, lsp, and skill accept
|
|
23
|
+
* the shorthand-or-pattern form; `write` is not on opencode's
|
|
24
|
+
* permission key list.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Maps Pi built-in tool names to the opencode AgentConfig.permission key
|
|
28
|
+
* that controls them. Only keys in OPENCODE_PERMISSION_KEYS_SUPPORTED are
|
|
29
|
+
* actionable; others compile but are silently ignored.
|
|
30
|
+
*
|
|
31
|
+
* Notable gaps:
|
|
32
|
+
* read → no supported opencode permission key (read is always available
|
|
33
|
+
* in opencode; it cannot be denied via the permission config).
|
|
34
|
+
* Omitted intentionally; declaring it in spec.tools[] adds it to
|
|
35
|
+
* the grant list but has no effect on opencode's actual behavior.
|
|
36
|
+
* write → edit (opencode gates file-write via the `edit` permission).
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Maps Pi built-in tool names to the opencode permission key.
|
|
40
|
+
* Per opencode.ai/docs config reference (and confirmed by codex pass 15
|
|
41
|
+
* of slice 2.4), `read` IS a supported permission key. Earlier codex
|
|
42
|
+
* passes had flagged it as unsupported based on the SDK types alone,
|
|
43
|
+
* but the runtime accepts it via the AgentConfig index signature.
|
|
44
|
+
*/
|
|
45
|
+
const PI_TO_OPENCODE_PERMISSION_KEY = {
|
|
46
|
+
// Pi built-in names (the four ADL currently recognizes for primary agents).
|
|
47
|
+
bash: "bash",
|
|
48
|
+
read: "read",
|
|
49
|
+
edit: "edit",
|
|
50
|
+
write: "edit",
|
|
51
|
+
// Additional names that appear in Pi subagent frontmatter (e.g.
|
|
52
|
+
// `tools: read, grep, find, ls`). These aren't ADL primary-agent built-
|
|
53
|
+
// ins, but subagent .md files are parsed independently and may list any
|
|
54
|
+
// tool Pi's registry exposes. Map each to its opencode equivalent so
|
|
55
|
+
// the subagent permission map is correct. Codex pass 2 of slice 2.5 caught.
|
|
56
|
+
grep: "grep",
|
|
57
|
+
find: "glob",
|
|
58
|
+
ls: "list",
|
|
59
|
+
// Accept opencode-native names too so frontmatter authors targeting
|
|
60
|
+
// opencode directly don't have to translate.
|
|
61
|
+
glob: "glob",
|
|
62
|
+
list: "list",
|
|
63
|
+
webfetch: "webfetch",
|
|
64
|
+
websearch: "websearch",
|
|
65
|
+
task: "task",
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Build an opencode root config from a CompiledSpec. Pure — does no
|
|
69
|
+
* file I/O and reads no env. Throws on inputs that cannot be safely
|
|
70
|
+
* mapped (e.g. custom Pi-extension tools targeting opencode), with an
|
|
71
|
+
* error message that names the unsupported field.
|
|
72
|
+
*
|
|
73
|
+
* `agentName` defaults to spec.metadata.name; callers can override
|
|
74
|
+
* (e.g. for hermetic test fixtures) when needed.
|
|
75
|
+
*/
|
|
76
|
+
export function buildOpencodeConfig(spec, options = {}) {
|
|
77
|
+
rejectUnsupportedTools(spec);
|
|
78
|
+
const agentName = options.agentName ?? spec.metadata.name;
|
|
79
|
+
const prompt = buildPromptString(spec, options.skillBodies ?? []);
|
|
80
|
+
const subagentDefs = options.subagentDefinitions ?? [];
|
|
81
|
+
const mcpServers = spec.mcpServers ?? [];
|
|
82
|
+
const permissions = buildPermissions(spec.tools ?? [], {
|
|
83
|
+
hasSubagents: subagentDefs.length > 0,
|
|
84
|
+
mcpServerNames: mcpServers.map((s) => s.name),
|
|
85
|
+
});
|
|
86
|
+
const agent = {
|
|
87
|
+
description: spec.metadata.description ??
|
|
88
|
+
`Agent ${spec.metadata.name} (compiled from ADL by agent-controller).`,
|
|
89
|
+
model: `${spec.model.provider}/${spec.model.name}`,
|
|
90
|
+
prompt,
|
|
91
|
+
...(spec.model.temperature !== undefined ? { temperature: spec.model.temperature } : {}),
|
|
92
|
+
permission: permissions,
|
|
93
|
+
};
|
|
94
|
+
// Build the agent map: primary agent first, then any subagents.
|
|
95
|
+
// Null-prototype object so user-supplied names like "constructor" or
|
|
96
|
+
// "__proto__" don't collide with Object.prototype. Codex pass 6 of slice 2.5.
|
|
97
|
+
const agentMap = Object.create(null);
|
|
98
|
+
agentMap[agentName] = agent;
|
|
99
|
+
for (const sa of options.subagentDefinitions ?? []) {
|
|
100
|
+
if (sa.name === agentName) {
|
|
101
|
+
throw new Error(`runtime-opencode: subagent name "${sa.name}" collides with the primary ` +
|
|
102
|
+
`agent name. Rename the subagent or the spec metadata.name.`);
|
|
103
|
+
}
|
|
104
|
+
if (NATIVE_OPENCODE_AGENTS.includes(sa.name)) {
|
|
105
|
+
throw new Error(`runtime-opencode: subagent name "${sa.name}" collides with an opencode ` +
|
|
106
|
+
`native agent. The adapter disables these natives to preserve ADL's ` +
|
|
107
|
+
`allowlist contract. Rename the subagent. ` +
|
|
108
|
+
`(Reserved names: ${NATIVE_OPENCODE_AGENTS.join(", ")}.)`);
|
|
109
|
+
}
|
|
110
|
+
if (Object.hasOwn(agentMap, sa.name)) {
|
|
111
|
+
throw new Error(`runtime-opencode: duplicate subagent name "${sa.name}" in spec.subagents[].`);
|
|
112
|
+
}
|
|
113
|
+
agentMap[sa.name] = buildSubagentConfig(sa, spec);
|
|
114
|
+
}
|
|
115
|
+
// Disable opencode's native agents so they cannot be delegated to via
|
|
116
|
+
// task tool. SKIP any native whose name collides with the primary agent
|
|
117
|
+
// (codex pass 5 of slice 2.5: the ADL schema allows metadata.name = "plan"
|
|
118
|
+
// / "build" / "general" / "explore", and the primary agent must remain
|
|
119
|
+
// executable in that case). Subagent names colliding with natives are
|
|
120
|
+
// already rejected in the loop above, so the only overlap to defend
|
|
121
|
+
// against here is the primary agent name.
|
|
122
|
+
for (const nativeName of NATIVE_OPENCODE_AGENTS) {
|
|
123
|
+
if (nativeName === agentName)
|
|
124
|
+
continue;
|
|
125
|
+
agentMap[nativeName] = {
|
|
126
|
+
description: "(disabled by agent-controller)",
|
|
127
|
+
model: `${spec.model.provider}/${spec.model.name}`,
|
|
128
|
+
prompt: "(disabled)",
|
|
129
|
+
disable: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const root = {
|
|
133
|
+
$schema: "https://opencode.ai/config.json",
|
|
134
|
+
agent: agentMap,
|
|
135
|
+
};
|
|
136
|
+
// MCP servers (optional). Omit the field entirely when the spec declares
|
|
137
|
+
// none — keeps the config minimal and matches opencode's "absent ≠ empty"
|
|
138
|
+
// expectation for top-level optional sections.
|
|
139
|
+
const mcp = buildMcpConfig(spec.mcpServers ?? []);
|
|
140
|
+
if (Object.keys(mcp).length > 0)
|
|
141
|
+
root.mcp = mcp;
|
|
142
|
+
return root;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Map a pre-resolved SubagentDefinition into opencode's AgentConfig shape.
|
|
146
|
+
* The subagent inherits the primary spec's model when its own model is
|
|
147
|
+
* not specified — keeping behavior predictable when the spec author
|
|
148
|
+
* doesn't pin a model per subagent.
|
|
149
|
+
*/
|
|
150
|
+
function buildSubagentConfig(sa, spec) {
|
|
151
|
+
// Subagent tools are a subset of Pi built-ins — map them through the same
|
|
152
|
+
// PI_TO_OPENCODE_PERMISSION_KEY table the primary agent uses. Anything not
|
|
153
|
+
// in the table is an unknown built-in and we throw (caller's responsibility
|
|
154
|
+
// to validate the .md file before passing in).
|
|
155
|
+
const grants = { "*": "deny" };
|
|
156
|
+
for (const key of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
|
|
157
|
+
grants[key] = "deny";
|
|
158
|
+
}
|
|
159
|
+
for (const toolName of sa.tools ?? []) {
|
|
160
|
+
if (!(toolName in PI_TO_OPENCODE_PERMISSION_KEY)) {
|
|
161
|
+
throw new Error(`runtime-opencode: subagent "${sa.name}" declares tool ${JSON.stringify(toolName)} ` +
|
|
162
|
+
`which is not a known Pi built-in. Allowed: ${Object.keys(PI_TO_OPENCODE_PERMISSION_KEY).join(", ")}.`);
|
|
163
|
+
}
|
|
164
|
+
grants[PI_TO_OPENCODE_PERMISSION_KEY[toolName]] = "allow";
|
|
165
|
+
}
|
|
166
|
+
// Normalize the subagent's model to opencode's `provider/model` format.
|
|
167
|
+
// Pi convention often writes just the bare model id (`claude-sonnet-4-...`),
|
|
168
|
+
// which opencode cannot resolve. If the subagent's model contains a `/`
|
|
169
|
+
// it's already fully qualified; otherwise prepend the primary spec's
|
|
170
|
+
// provider. Codex pass 1 of slice 2.5 caught the missing normalization.
|
|
171
|
+
const subagentModel = sa.model
|
|
172
|
+
? (sa.model.includes("/") ? sa.model : `${spec.model.provider}/${sa.model}`)
|
|
173
|
+
: `${spec.model.provider}/${spec.model.name}`;
|
|
174
|
+
return {
|
|
175
|
+
description: sa.description,
|
|
176
|
+
model: subagentModel,
|
|
177
|
+
prompt: sa.systemPrompt,
|
|
178
|
+
mode: "subagent",
|
|
179
|
+
permission: grants,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Reject MCP server names whose `<name>_*` allow pattern would override the
|
|
184
|
+
* deny baseline for an opencode built-in. opencode matches permission keys
|
|
185
|
+
* as wildcard patterns, so a server named "repo" would have `repo_*: "allow"`
|
|
186
|
+
* which also matches the built-in `repo_clone` / `repo_overview` keys —
|
|
187
|
+
* effectively granting them despite the baseline deny.
|
|
188
|
+
*
|
|
189
|
+
* Built into the deny baseline today: doom_loop, external_directory,
|
|
190
|
+
* repo_clone, repo_overview. The forbidden MCP-name prefixes are therefore:
|
|
191
|
+
* doom, external, repo. The check is derived from the baseline (not
|
|
192
|
+
* hardcoded) so it stays accurate if the baseline grows.
|
|
193
|
+
*
|
|
194
|
+
* Codex pass 3 of slice 2.5 caught this allowlist-broadening bug.
|
|
195
|
+
*/
|
|
196
|
+
function rejectCollidingMcpName(name) {
|
|
197
|
+
// We emit both raw `<name>_*` and sanitized `<sanitized>_*` allow patterns
|
|
198
|
+
// (because opencode's actual tool-id format isn't fully determined). Check
|
|
199
|
+
// both prefixes against the built-in deny baseline so neither pattern can
|
|
200
|
+
// accidentally re-grant a built-in.
|
|
201
|
+
const sanitized = sanitizeMcpName(name);
|
|
202
|
+
const candidatePrefixes = sanitized === name ? [`${name}_`] : [`${name}_`, `${sanitized}_`];
|
|
203
|
+
for (const builtinKey of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
|
|
204
|
+
for (const candidatePrefix of candidatePrefixes) {
|
|
205
|
+
if (builtinKey.startsWith(candidatePrefix)) {
|
|
206
|
+
throw new Error(`runtime-opencode: MCP server name "${name}" collides with the opencode ` +
|
|
207
|
+
`built-in permission key "${builtinKey}". The implicit "${candidatePrefix}*" allow ` +
|
|
208
|
+
`grant for this server's tools would also match the built-in, breaking the ` +
|
|
209
|
+
`ADL deny-by-default contract. Rename the MCP server in spec.mcpServers[].`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Mirror opencode's MCP-tool-id sanitization rule: replace every character
|
|
216
|
+
* outside [A-Za-z0-9] with `_`. opencode generates tool ids of the form
|
|
217
|
+
* `<sanitized-server-name>_<tool-name>`; our permission keys must use the
|
|
218
|
+
* same sanitization to match. Codex pass 4 of slice 2.5 caught the
|
|
219
|
+
* mismatch for server names like "github.com".
|
|
220
|
+
*/
|
|
221
|
+
function sanitizeMcpName(name) {
|
|
222
|
+
return name.replace(/[^A-Za-z0-9]/g, "_");
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Reject MCP server names that contain characters which would create
|
|
226
|
+
* overmatching wildcard patterns in opencode's permission grants. We emit
|
|
227
|
+
* the raw name as `<name>_*` in the permission map; if the name contains
|
|
228
|
+
* glob metacharacters (`*`, `?`, `[`, `]`, `{`, `}`), the resulting pattern
|
|
229
|
+
* could match arbitrary opencode built-ins (e.g. `repo*_*` matches `repo_clone`)
|
|
230
|
+
* and re-enable tools outside the ADL allowlist. Codex pass 8 of slice 2.5.
|
|
231
|
+
*
|
|
232
|
+
* Allowed character set is the same one MCP servers conventionally use for
|
|
233
|
+
* identifiers: alphanumerics plus `.`, `-`, `_`. Anything else throws.
|
|
234
|
+
*/
|
|
235
|
+
function rejectUnsafeMcpNameChars(name) {
|
|
236
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
237
|
+
throw new Error(`runtime-opencode: MCP server name "${name}" contains characters outside ` +
|
|
238
|
+
`the allowed set [A-Za-z0-9._-]. Glob metacharacters (* ? [ ]) and other ` +
|
|
239
|
+
`non-identifier characters could create overmatching opencode permission ` +
|
|
240
|
+
`wildcards that re-enable built-in tools outside the ADL allowlist. ` +
|
|
241
|
+
`Rename the MCP server in spec.mcpServers[].`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Map ADL spec.mcpServers[] to opencode's cfg.mcp shape.
|
|
246
|
+
*
|
|
247
|
+
* Transport mapping:
|
|
248
|
+
* - "stdio" → { type: "local", command: [command, ...args], environment: env }
|
|
249
|
+
* - "streamable-http" → { type: "remote", url, headers }
|
|
250
|
+
* - "sse" → { type: "remote", url, headers }
|
|
251
|
+
*
|
|
252
|
+
* opencode does not distinguish streamable-http from sse — both become
|
|
253
|
+
* type="remote". The transport-specific behavior is negotiated by the
|
|
254
|
+
* remote endpoint at connect time.
|
|
255
|
+
*
|
|
256
|
+
* lifecycle (eager/lazy) has no direct opencode equivalent; we set
|
|
257
|
+
* `enabled: true` for both ("eager" matches; "lazy" is approximated since
|
|
258
|
+
* opencode lazily fetches tools anyway).
|
|
259
|
+
*/
|
|
260
|
+
function buildMcpConfig(servers) {
|
|
261
|
+
// Null-prototype object: ADL only constrains MCP names to non-empty strings,
|
|
262
|
+
// so names like "constructor", "toString", or "__proto__" must work as plain
|
|
263
|
+
// map keys without colliding with Object.prototype or mutating the prototype
|
|
264
|
+
// chain. Codex pass 6 of slice 2.5.
|
|
265
|
+
const out = Object.create(null);
|
|
266
|
+
const sanitizedNames = new Map(); // sanitized → raw
|
|
267
|
+
for (const s of servers) {
|
|
268
|
+
rejectUnsafeMcpNameChars(s.name);
|
|
269
|
+
if (Object.hasOwn(out, s.name)) {
|
|
270
|
+
throw new Error(`runtime-opencode: duplicate MCP server name "${s.name}" in spec.mcpServers[].`);
|
|
271
|
+
}
|
|
272
|
+
const sanitized = sanitizeMcpName(s.name);
|
|
273
|
+
const existing = sanitizedNames.get(sanitized);
|
|
274
|
+
if (existing !== undefined && existing !== s.name) {
|
|
275
|
+
throw new Error(`runtime-opencode: MCP server names "${existing}" and "${s.name}" both ` +
|
|
276
|
+
`sanitize to "${sanitized}". opencode generates tool ids under the sanitized ` +
|
|
277
|
+
`name, so the two servers' tools would collide in the same namespace. ` +
|
|
278
|
+
`Rename one in spec.mcpServers[].`);
|
|
279
|
+
}
|
|
280
|
+
sanitizedNames.set(sanitized, s.name);
|
|
281
|
+
rejectCollidingMcpName(s.name);
|
|
282
|
+
if (s.transport === "stdio") {
|
|
283
|
+
if (!s.command) {
|
|
284
|
+
throw new Error(`runtime-opencode: MCP server "${s.name}" uses transport "stdio" but has no command field.`);
|
|
285
|
+
}
|
|
286
|
+
const local = {
|
|
287
|
+
type: "local",
|
|
288
|
+
command: [s.command, ...(s.args ?? [])],
|
|
289
|
+
};
|
|
290
|
+
if (s.env && Object.keys(s.env).length > 0)
|
|
291
|
+
local.environment = s.env;
|
|
292
|
+
out[s.name] = local;
|
|
293
|
+
}
|
|
294
|
+
else if (s.transport === "streamable-http" || s.transport === "sse") {
|
|
295
|
+
if (!s.url) {
|
|
296
|
+
throw new Error(`runtime-opencode: MCP server "${s.name}" uses transport "${s.transport}" but has no url field.`);
|
|
297
|
+
}
|
|
298
|
+
const remote = {
|
|
299
|
+
type: "remote",
|
|
300
|
+
url: s.url,
|
|
301
|
+
};
|
|
302
|
+
if (s.headers && Object.keys(s.headers).length > 0)
|
|
303
|
+
remote.headers = s.headers;
|
|
304
|
+
out[s.name] = remote;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
throw new Error(`runtime-opencode: MCP server "${s.name}" has unknown transport "${s.transport}". ` +
|
|
308
|
+
`Expected one of: stdio, streamable-http, sse.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Reject specs that declare custom Pi-extension tools when targeting
|
|
315
|
+
* opencode. The check is: any spec.tools[] entry that has `entrypoint`
|
|
316
|
+
* set and `builtin` unset is a Pi-extension tool. opencode cannot load
|
|
317
|
+
* those, so we fail with a clear error that names every offending tool.
|
|
318
|
+
*
|
|
319
|
+
* spec.extensions[] is intentionally not validated here — slice 2.3
|
|
320
|
+
* will design extension handling (likely also a reject for opencode,
|
|
321
|
+
* since extension JS modules are Pi-specific). For now we silently
|
|
322
|
+
* pass extensions through; the prompt construction ignores them.
|
|
323
|
+
*/
|
|
324
|
+
function rejectUnsupportedTools(spec) {
|
|
325
|
+
const offenders = [];
|
|
326
|
+
for (const t of spec.tools ?? []) {
|
|
327
|
+
if (isCustomPiExtensionTool(t)) {
|
|
328
|
+
offenders.push(t.name);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (offenders.length > 0) {
|
|
332
|
+
throw new Error(`runtime-opencode: spec.tools[] contains custom Pi-extension tools ` +
|
|
333
|
+
`that cannot run on opencode: ${offenders.join(", ")}. ` +
|
|
334
|
+
`Only Pi built-in tools (bash, read, edit, write) are supported on ` +
|
|
335
|
+
`the opencode adapter today. See docs/architecture/harness-matrix.md ` +
|
|
336
|
+
`for the supported-feature table; custom Pi extensions are documented ` +
|
|
337
|
+
`as ❌ for opencode.`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function isCustomPiExtensionTool(t) {
|
|
341
|
+
// A Pi built-in declared in spec.tools[] has builtin=true and no
|
|
342
|
+
// entrypoint. A custom Pi-extension tool has entrypoint set and
|
|
343
|
+
// builtin not set. Anything else (e.g. entrypoint set + builtin
|
|
344
|
+
// true) shouldn't happen given the compiler, but we treat as not-
|
|
345
|
+
// custom to err on the permissive side rather than reject a
|
|
346
|
+
// valid-looking spec.
|
|
347
|
+
return Boolean(t.entrypoint) && !t.builtin;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Build the prompt string opencode uses as the agent's system prompt.
|
|
351
|
+
*
|
|
352
|
+
* Composition (in order):
|
|
353
|
+
* 1. HONESTY_PREAMBLE — same anti-hallucination preamble as Pi
|
|
354
|
+
* adapter. Even though opencode is also susceptible to
|
|
355
|
+
* <invoke>/<function_calls> XML in message bodies, having the
|
|
356
|
+
* preamble at session start lowers the rate. Slice 2.4 will add
|
|
357
|
+
* the runtime XML detector to catch what slips through.
|
|
358
|
+
* 2. Persona role and instructions (when present), formatted to
|
|
359
|
+
* match Pi adapter's buildSystemPrompt() so spec authors don't
|
|
360
|
+
* have to mentally branch on which adapter they're targeting.
|
|
361
|
+
*
|
|
362
|
+
* Skill bodies will be appended here in slice 2.3 once we add fs
|
|
363
|
+
* reads to the mapping pipeline. For now this slice is pure.
|
|
364
|
+
*/
|
|
365
|
+
function buildPromptString(spec, skillBodies) {
|
|
366
|
+
const parts = [HONESTY_PREAMBLE];
|
|
367
|
+
if (spec.persona?.role)
|
|
368
|
+
parts.push(`# Role\n${spec.persona.role}`);
|
|
369
|
+
if (spec.persona?.instructions)
|
|
370
|
+
parts.push(`# Instructions\n${spec.persona.instructions}`);
|
|
371
|
+
// Skill bodies (when present). Wrapped with the same "skill may describe
|
|
372
|
+
// tools you lack" preamble Pi adapter uses via wrapSkillBody(). This is
|
|
373
|
+
// the v0.1.7 "active by default" pattern: opencode doesn't have a skills
|
|
374
|
+
// concept, so we inline the bodies so the model unconditionally sees them.
|
|
375
|
+
for (const s of skillBodies) {
|
|
376
|
+
if (!s.body.trim())
|
|
377
|
+
continue;
|
|
378
|
+
parts.push(wrapSkillBody(s.name, s.body.trim()));
|
|
379
|
+
}
|
|
380
|
+
return parts.join("\n\n");
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* All opencode permission keys to set in the deny baseline.
|
|
384
|
+
*
|
|
385
|
+
* This list combines:
|
|
386
|
+
* (a) Keys typed in AgentConfig.permission in @opencode-ai/sdk:
|
|
387
|
+
* edit, bash, webfetch, doom_loop, external_directory
|
|
388
|
+
* (b) Keys documented in opencode.ai/docs config reference that the SDK
|
|
389
|
+
* types do not yet expose but the runtime accepts via AgentConfig's
|
|
390
|
+
* index signature `[key: string]: unknown`:
|
|
391
|
+
* read, glob, grep, list, task, skill, lsp, question, websearch
|
|
392
|
+
* (c) A wildcard "*": "deny" baseline as a belt-and-suspenders catch-all.
|
|
393
|
+
* Codex pass 11 raised that this was a no-op; codex pass 15 said the
|
|
394
|
+
* opencode runtime does support it per the docs config reference.
|
|
395
|
+
* We include it because if it becomes effective in a future opencode
|
|
396
|
+
* release, having it present is exactly what we want; if it's still
|
|
397
|
+
* a no-op today, the explicit key denials above cover the known tools.
|
|
398
|
+
*
|
|
399
|
+
* When the opencode SDK types are updated to expose more keys, remove those
|
|
400
|
+
* keys from the undocumented-but-runtime-supported list and keep them here.
|
|
401
|
+
*/
|
|
402
|
+
/**
|
|
403
|
+
* opencode ships these named agents as natives. They are present in
|
|
404
|
+
* cfg.agent by default (see AgentConfig types in the SDK). If we don't
|
|
405
|
+
* explicitly disable them, a spec that grants `task: allow` (to invoke its
|
|
406
|
+
* own declared subagents) can also delegate to these natives — which carry
|
|
407
|
+
* their own tool defaults, bypassing the ADL allowlist contract.
|
|
408
|
+
* Codex pass 4 of slice 2.5 caught this bypass.
|
|
409
|
+
*
|
|
410
|
+
* We disable all four unconditionally. Specs that want to use them can
|
|
411
|
+
* re-export them via spec.subagents[] with explicit grants.
|
|
412
|
+
*/
|
|
413
|
+
const NATIVE_OPENCODE_AGENTS = [
|
|
414
|
+
"plan",
|
|
415
|
+
"build",
|
|
416
|
+
"general",
|
|
417
|
+
"explore",
|
|
418
|
+
// Experimental native agent enabled by OPENCODE_EXPERIMENTAL_SCOUT. Listed
|
|
419
|
+
// here defensively so the allowlist contract holds even when the operator
|
|
420
|
+
// has scout enabled at the opencode level. Codex pass 6 of slice 2.5.
|
|
421
|
+
"scout",
|
|
422
|
+
];
|
|
423
|
+
const OPENCODE_PERMISSION_KEYS_DENY_BASELINE = [
|
|
424
|
+
// Typed in AgentConfig.permission (definite SDK support)
|
|
425
|
+
"edit",
|
|
426
|
+
"bash",
|
|
427
|
+
"webfetch",
|
|
428
|
+
"doom_loop",
|
|
429
|
+
"external_directory",
|
|
430
|
+
// Documented in opencode.ai/docs (runtime support, not yet in SDK types)
|
|
431
|
+
"read",
|
|
432
|
+
"glob",
|
|
433
|
+
"grep",
|
|
434
|
+
"list",
|
|
435
|
+
"task",
|
|
436
|
+
"skill",
|
|
437
|
+
"lsp",
|
|
438
|
+
"question",
|
|
439
|
+
"websearch",
|
|
440
|
+
// Additional keys present in the bundled SDK v2 types (codex pass 26).
|
|
441
|
+
"todowrite",
|
|
442
|
+
"repo_clone",
|
|
443
|
+
"repo_overview",
|
|
444
|
+
// Wildcard catch-all
|
|
445
|
+
"*",
|
|
446
|
+
];
|
|
447
|
+
/**
|
|
448
|
+
* Build the permissions map for opencode from spec.tools[].
|
|
449
|
+
*
|
|
450
|
+
* Implements ADL's allowlist semantic: start with explicit "deny" for
|
|
451
|
+
* every known opencode tool, then overlay "allow" for the ones the spec
|
|
452
|
+
* declares. This ensures that a `tools: []` spec actually runs with no
|
|
453
|
+
* tools, matching Pi adapter behavior.
|
|
454
|
+
*
|
|
455
|
+
* Always returns a non-empty object (at minimum all-deny). We omit the
|
|
456
|
+
* `undefined` return to make the caller simpler and the intent explicit.
|
|
457
|
+
*/
|
|
458
|
+
function buildPermissions(tools, context = { hasSubagents: false, mcpServerNames: [] }) {
|
|
459
|
+
// Start with the wildcard catch-all FIRST, then enumerate specific denies.
|
|
460
|
+
// opencode uses last-match-wins semantics for permission rules, so if "*":
|
|
461
|
+
// "deny" is serialized AFTER specific allows, it overrides them and renders
|
|
462
|
+
// all explicitly granted tools unusable.
|
|
463
|
+
const grants = { "*": "deny" };
|
|
464
|
+
for (const key of OPENCODE_PERMISSION_KEYS_DENY_BASELINE) {
|
|
465
|
+
grants[key] = "deny";
|
|
466
|
+
}
|
|
467
|
+
// Overlay "allow" for Pi built-ins the spec explicitly declared.
|
|
468
|
+
for (const t of tools) {
|
|
469
|
+
if (!t.builtin)
|
|
470
|
+
continue;
|
|
471
|
+
if (!(t.name in PI_TO_OPENCODE_PERMISSION_KEY)) {
|
|
472
|
+
throw new Error(`runtime-opencode: Pi built-in tool ${JSON.stringify(t.name)} is not in ` +
|
|
473
|
+
`PI_TO_OPENCODE_PERMISSION_KEY. Add an entry.`);
|
|
474
|
+
}
|
|
475
|
+
if (t.config && Object.keys(t.config).length > 0) {
|
|
476
|
+
// As of v0.1.11 the canonical rejection for built-in-with-config lives
|
|
477
|
+
// in the Go compiler (cli/internal/adl/compiler.go) so `agentctl
|
|
478
|
+
// validate`/`compile` catches the bug before any adapter starts. We
|
|
479
|
+
// keep this runtime check as defense-in-depth: if a future schema
|
|
480
|
+
// change or a path that bypasses the compiler injects a built-in
|
|
481
|
+
// with config, the opencode adapter still refuses to start with a
|
|
482
|
+
// clear error. The Pi adapter relies entirely on the compile-time
|
|
483
|
+
// rejection (no equivalent runtime check today).
|
|
484
|
+
throw new Error(`runtime-opencode: spec.tools declares built-in "${t.name}" with config ` +
|
|
485
|
+
`(${JSON.stringify(Object.keys(t.config))}). This should have been rejected at ` +
|
|
486
|
+
`compile time by the Go CLI; reaching this code path means either an outdated ` +
|
|
487
|
+
`CLI binary or a hand-crafted CompiledSpec. For bash command allowlisting, use ` +
|
|
488
|
+
`the @gotgenes/pi-permission-system extension via spec.extensions[].source: ` +
|
|
489
|
+
`npm:@gotgenes/pi-permission-system.`);
|
|
490
|
+
}
|
|
491
|
+
const opencodeKey = PI_TO_OPENCODE_PERMISSION_KEY[t.name];
|
|
492
|
+
grants[opencodeKey] = "allow";
|
|
493
|
+
}
|
|
494
|
+
// Slice 2.5 codex pass 1: when the spec declares subagents, the primary
|
|
495
|
+
// agent needs the `task` permission to invoke them via opencode's task
|
|
496
|
+
// tool. Without this, opencode connects all child agents but the parent
|
|
497
|
+
// is denied access to delegate. Declaring subagents IS the implicit grant.
|
|
498
|
+
if (context.hasSubagents) {
|
|
499
|
+
grants["task"] = "allow";
|
|
500
|
+
}
|
|
501
|
+
// Slice 2.5: each MCP server's tools appear in opencode under permission
|
|
502
|
+
// keys of the form `<server-prefix>_<tool>`. Codex passes 4 and 7 of this
|
|
503
|
+
// slice gave contradictory accounts of whether opencode uses the raw name
|
|
504
|
+
// (e.g. `my-mcp_*`) or a sanitized form (e.g. `my_mcp_*`). Rather than guess
|
|
505
|
+
// and risk leaving the allow grant ineffective for one interpretation,
|
|
506
|
+
// emit BOTH patterns. For alphanumeric-only names the two collapse to the
|
|
507
|
+
// same key; for names with punctuation, whichever form opencode actually
|
|
508
|
+
// generates is covered. A non-matching pattern is a harmless no-op.
|
|
509
|
+
for (const serverName of context.mcpServerNames) {
|
|
510
|
+
grants[`${serverName}_*`] = "allow";
|
|
511
|
+
const sanitized = sanitizeMcpName(serverName);
|
|
512
|
+
if (sanitized !== serverName) {
|
|
513
|
+
grants[`${sanitized}_*`] = "allow";
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return grants;
|
|
517
|
+
}
|