@rynfar/meridian 1.63.0 → 1.65.0

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.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/dist/{cli-hdyptm7z.js → cli-7cts44b5.js} +15672 -4267
  3. package/dist/cli-n8t34zmq.js +939 -0
  4. package/dist/{cli-m0p2bc8v.js → cli-pdpry6q0.js} +16 -3
  5. package/dist/cli.js +72 -13
  6. package/dist/meridian-v2.js +347 -0
  7. package/dist/{profileCli-39vshwdn.js → profileCli-ap7eg985.js} +2 -2
  8. package/dist/{profiles-0pyeqayw.js → profiles-4ajzjqhm.js} +1 -1
  9. package/dist/proxy/adapter.d.ts +16 -0
  10. package/dist/proxy/adapter.d.ts.map +1 -1
  11. package/dist/proxy/adapters/opencode.d.ts.map +1 -1
  12. package/dist/proxy/errors.d.ts.map +1 -1
  13. package/dist/proxy/passthroughTools.d.ts.map +1 -1
  14. package/dist/proxy/priorityAttestation.d.ts +35 -0
  15. package/dist/proxy/priorityAttestation.d.ts.map +1 -0
  16. package/dist/proxy/routing.d.ts +16 -2
  17. package/dist/proxy/routing.d.ts.map +1 -1
  18. package/dist/proxy/server.d.ts.map +1 -1
  19. package/dist/proxy/session/cache.d.ts +25 -2
  20. package/dist/proxy/session/cache.d.ts.map +1 -1
  21. package/dist/proxy/session/lineage.d.ts +1 -1
  22. package/dist/proxy/session/lineage.d.ts.map +1 -1
  23. package/dist/proxy/session/turnCoordinator.d.ts +2 -0
  24. package/dist/proxy/session/turnCoordinator.d.ts.map +1 -1
  25. package/dist/proxy/sessionStore.d.ts +133 -0
  26. package/dist/proxy/sessionStore.d.ts.map +1 -1
  27. package/dist/proxy/settings.d.ts +2 -0
  28. package/dist/proxy/settings.d.ts.map +1 -1
  29. package/dist/proxy/setup.d.ts +35 -10
  30. package/dist/proxy/setup.d.ts.map +1 -1
  31. package/dist/server.js +5 -5
  32. package/dist/setup-8fwgwhqh.js +33 -0
  33. package/package.json +9 -5
  34. package/plugin/meridian-v2.ts +335 -0
  35. package/plugin/meridian.ts +125 -20
  36. package/plugin/priority-attestation.ts +150 -0
  37. package/dist/cli-pc0mtjjv.js +0 -167
  38. package/dist/setup-0x573t61.js +0 -19
  39. package/dist/{cli-xfbhn15a.js → cli-0ed6j0vk.js} +3 -3
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Meridian OpenCode plugin for the V2 beta line.
3
+ *
4
+ * OpenCode V2 runs hidden title and summary requests in the parent session,
5
+ * often in parallel with the visible first turn. V2's core gives all of those
6
+ * requests the same session-affinity headers. Meridian must detach the hidden
7
+ * one-shots before they reach the proxy or they can advance the visible
8
+ * conversation's durable lineage.
9
+ *
10
+ * The model hook applies the identity as early as V2 permits. The HTTP hook
11
+ * enforces it again at the final request boundary. This removes stale or
12
+ * spoofed control headers regardless of header casing.
13
+ *
14
+ * NOTE: OpenCode-specific. Keep this separate from plugin/meridian.ts: V1
15
+ * expects a default plugin function and V2 expects a Plugin.define() object.
16
+ */
17
+
18
+ import * as Plugin from "@opencode-ai/plugin/promise/plugin"
19
+ import {
20
+ PRIORITY_ATTESTATION_HEADER,
21
+ createPriorityAttestation,
22
+ deleteHeader,
23
+ getHeader,
24
+ setHeader,
25
+ type MutableHeaders,
26
+ } from "./priority-attestation"
27
+
28
+ /** Exact V2 host used to compile and validate this beta-only integration. */
29
+ export const SUPPORTED_OPENCODE_V2_VERSION = "0.0.0-beta-18314"
30
+
31
+ const MERIDIAN_PROVIDERS = new Set(["anthropic", "meridian"])
32
+ const PARENT_SESSION_ONE_SHOTS = new Set(["title", "summary"])
33
+ const ATTACHED_COMPACTION_AGENT = "compaction"
34
+
35
+ const SESSION_AFFINITY_HEADERS = [
36
+ "x-opencode-session",
37
+ "x-session-affinity",
38
+ "x-session-id",
39
+ "x-parent-session-id",
40
+ ] as const
41
+
42
+ const MERIDIAN_CONTROL_HEADERS = [
43
+ "x-meridian-source",
44
+ "x-opencode-agent-name",
45
+ "x-opencode-agent-mode",
46
+ PRIORITY_ATTESTATION_HEADER,
47
+ ] as const
48
+
49
+ export interface AgentTraits {
50
+ mode: "primary" | "subagent"
51
+ hidden: boolean
52
+ }
53
+
54
+ const BUILTIN_AGENT_TRAITS: Record<string, AgentTraits> = {
55
+ build: { mode: "primary", hidden: false },
56
+ plan: { mode: "primary", hidden: false },
57
+ general: { mode: "subagent", hidden: false },
58
+ explore: { mode: "subagent", hidden: false },
59
+ // Exact beta-18314 defines all three hidden internal agents as primary.
60
+ title: { mode: "primary", hidden: true },
61
+ summary: { mode: "primary", hidden: true },
62
+ compaction: { mode: "primary", hidden: true },
63
+ }
64
+
65
+ export function fallbackAgentTraits(agent: string): AgentTraits {
66
+ return BUILTIN_AGENT_TRAITS[agent] ?? { mode: "primary", hidden: false }
67
+ }
68
+
69
+ function safeAgentName(agent: string): string {
70
+ return agent.replace(/[^\x20-\x7E]/g, "").trim() || "unknown"
71
+ }
72
+
73
+ export function shouldDetachFromParentSession(agent: string, _traits: AgentTraits): boolean {
74
+ // The exact built-in ID is authoritative. `hidden` is presentation config:
75
+ // making the built-in visible does not stop V2 from running its parent-
76
+ // session title/summary job concurrently with the primary turn.
77
+ return PARENT_SESSION_ONE_SHOTS.has(agent)
78
+ }
79
+
80
+ /**
81
+ * Rewrite one V2 request's identity without changing its body or model input.
82
+ * This pure helper is shared by model.request and http.request.
83
+ */
84
+ export function applyMeridianV2Headers(
85
+ headers: MutableHeaders,
86
+ input: { sessionID: string; agent: string; traits: AgentTraits },
87
+ ): void {
88
+ for (const name of SESSION_AFFINITY_HEADERS) deleteHeader(headers, name)
89
+ for (const name of MERIDIAN_CONTROL_HEADERS) deleteHeader(headers, name)
90
+
91
+ const name = safeAgentName(input.agent)
92
+ const detached = shouldDetachFromParentSession(input.agent, input.traits)
93
+
94
+ if (detached) {
95
+ setHeader(headers, "x-meridian-source", `subagent-${name}`)
96
+ } else {
97
+ // Set every V2 affinity spelling from the trusted hook input. Do not retain
98
+ // provider-config values that can bind this request to another session.
99
+ setHeader(headers, "x-opencode-session", input.sessionID)
100
+ setHeader(headers, "x-session-affinity", input.sessionID)
101
+ setHeader(headers, "x-session-id", input.sessionID)
102
+ if (input.agent === ATTACHED_COMPACTION_AGENT) {
103
+ // Source selects the base model tier without making the adapter append
104
+ // `#compaction` to the primary session key.
105
+ setHeader(headers, "x-meridian-source", "subagent-compaction")
106
+ }
107
+ }
108
+
109
+ const internalMode = PARENT_SESSION_ONE_SHOTS.has(input.agent)
110
+ ? "subagent"
111
+ : input.agent === ATTACHED_COMPACTION_AGENT ? "primary" : input.traits.mode
112
+ setHeader(headers, "x-opencode-agent-name", name)
113
+ setHeader(headers, "x-opencode-agent-mode", internalMode)
114
+ }
115
+
116
+ const INTERNAL_ROUTING_AGENT_IDS = new Set(["title", "summary", "compaction"])
117
+ const AGENT_CACHE_MAX = 256
118
+ const AGENT_CACHE_TTL_MS = 5_000
119
+ const LOOKUP_TIMEOUT_MS = 250
120
+ const MAX_CONTEXT_ENTRIES = 2_048
121
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
122
+
123
+ type ExactAgentMode = "primary" | "subagent" | "all"
124
+ type ResolvedAgentMetadata = {
125
+ readonly traits: AgentTraits
126
+ readonly exactMode: ExactAgentMode | undefined
127
+ readonly authoritative: boolean
128
+ }
129
+
130
+ function isRecord(value: unknown): value is Record<string, unknown> {
131
+ return typeof value === "object" && value !== null && !Array.isArray(value)
132
+ }
133
+
134
+ async function withTimeout<T>(pending: Promise<T>, timeoutMs: number): Promise<T | undefined> {
135
+ let timer: ReturnType<typeof setTimeout> | undefined
136
+ const timedOut = new Promise<undefined>((resolve) => {
137
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
138
+ timer.unref?.()
139
+ })
140
+ try {
141
+ return await Promise.race([pending, timedOut])
142
+ } finally {
143
+ if (timer) clearTimeout(timer)
144
+ }
145
+ }
146
+
147
+ export function isRootV2Session(value: unknown, sessionID: string): boolean {
148
+ return isRecord(value)
149
+ && value.id === sessionID
150
+ && value.parentID === undefined
151
+ && value.fork === undefined
152
+ }
153
+
154
+ /**
155
+ * Find the exact host message ID that initiated the active model loop.
156
+ * Assistant/tool steps and selection records belong to the current loop and
157
+ * are skipped. A synthetic/compaction/shell/skill/system or unknown initiator
158
+ * fails closed instead of reusing an older human ID.
159
+ */
160
+ type V2HumanTurn = { readonly id: string; readonly issuedAt: number }
161
+
162
+ export function findLatestV2HumanTurn(value: unknown): V2HumanTurn | undefined {
163
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_CONTEXT_ENTRIES) return undefined
164
+ for (let index = value.length - 1; index >= 0; index -= 1) {
165
+ const message = value[index]
166
+ if (!isRecord(message) || typeof message.type !== "string") return undefined
167
+ if (
168
+ message.type === "assistant"
169
+ || message.type === "agent-switched"
170
+ || message.type === "model-switched"
171
+ || message.type === "location-switched"
172
+ ) {
173
+ continue
174
+ }
175
+ if (message.type !== "user" || typeof message.id !== "string" || !SAFE_ID_PATTERN.test(message.id)) {
176
+ return undefined
177
+ }
178
+ const time = isRecord(message.time) ? message.time : undefined
179
+ const createdAt = time?.created
180
+ if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0) return undefined
181
+ return { id: message.id, issuedAt: Math.floor(createdAt / 1000) }
182
+ }
183
+ return undefined
184
+ }
185
+
186
+ export function findLatestV2HumanMessageId(value: unknown): string | undefined {
187
+ return findLatestV2HumanTurn(value)?.id
188
+ }
189
+
190
+ const MeridianV2Plugin = Plugin.define({
191
+ id: "meridian",
192
+ setup: async (context) => {
193
+ const traitsByAgent = new Map<string, {
194
+ expiresAt: number
195
+ request: symbol
196
+ pending: Promise<ResolvedAgentMetadata>
197
+ }>()
198
+ const registered: Array<{ dispose: () => Promise<void> }> = []
199
+
200
+ const resolveAgentTraits = (agent: string, refresh = false): Promise<ResolvedAgentMetadata> => {
201
+ const cached = traitsByAgent.get(agent)
202
+ if (!refresh && cached && cached.expiresAt > Date.now()) return cached.pending
203
+ if (refresh) traitsByAgent.delete(agent)
204
+
205
+ const request = Symbol(agent)
206
+ const pending = (async (): Promise<ResolvedAgentMetadata> => {
207
+ const controller = new AbortController()
208
+ try {
209
+ const result = await withTimeout(
210
+ context.agent.get({ agentID: agent }, { signal: controller.signal }),
211
+ LOOKUP_TIMEOUT_MS,
212
+ )
213
+ const data = result?.data
214
+ if (
215
+ !data
216
+ || (data.mode !== "primary" && data.mode !== "subagent" && data.mode !== "all")
217
+ || typeof data.hidden !== "boolean"
218
+ ) {
219
+ if (traitsByAgent.get(agent)?.request === request) traitsByAgent.delete(agent)
220
+ return { traits: fallbackAgentTraits(agent), exactMode: undefined, authoritative: false }
221
+ }
222
+ return {
223
+ traits: {
224
+ mode: data.mode === "subagent" ? "subagent" : "primary",
225
+ hidden: data.hidden,
226
+ },
227
+ exactMode: data.mode,
228
+ authoritative: true,
229
+ }
230
+ } catch {
231
+ // Retry a transient failure at the final HTTP boundary or next turn.
232
+ if (traitsByAgent.get(agent)?.request === request) traitsByAgent.delete(agent)
233
+ return { traits: fallbackAgentTraits(agent), exactMode: undefined, authoritative: false }
234
+ } finally {
235
+ controller.abort()
236
+ }
237
+ })()
238
+ traitsByAgent.delete(agent)
239
+ traitsByAgent.set(agent, { request, expiresAt: Date.now() + AGENT_CACHE_TTL_MS, pending })
240
+ while (traitsByAgent.size > AGENT_CACHE_MAX) {
241
+ const oldest = traitsByAgent.keys().next().value
242
+ if (oldest === undefined) break
243
+ traitsByAgent.delete(oldest)
244
+ }
245
+ return pending
246
+ }
247
+
248
+ const apply = async (
249
+ input: { sessionID: string; agent: string; model: { providerID: string } },
250
+ headers: MutableHeaders,
251
+ refreshTraits = false,
252
+ ): Promise<ResolvedAgentMetadata | undefined> => {
253
+ if (!MERIDIAN_PROVIDERS.has(String(input.model.providerID))) return undefined
254
+ const agent = String(input.agent)
255
+ const metadata = await resolveAgentTraits(agent, refreshTraits)
256
+ applyMeridianV2Headers(headers, {
257
+ sessionID: String(input.sessionID),
258
+ agent,
259
+ traits: metadata.traits,
260
+ })
261
+ return metadata
262
+ }
263
+
264
+ const resolveHumanTurn = async (
265
+ input: { sessionID: string; agent: string },
266
+ headers: MutableHeaders,
267
+ metadata: ResolvedAgentMetadata,
268
+ ): Promise<V2HumanTurn | undefined> => {
269
+ const sessionID = String(input.sessionID)
270
+ const agent = String(input.agent)
271
+ if (
272
+ !metadata.authoritative
273
+ || metadata.exactMode !== "primary"
274
+ || metadata.traits.hidden
275
+ || INTERNAL_ROUTING_AGENT_IDS.has(agent)
276
+ || safeAgentName(agent) !== agent
277
+ || getHeader(headers, "x-meridian-profile") !== undefined
278
+ ) {
279
+ return undefined
280
+ }
281
+
282
+ const controller = new AbortController()
283
+ try {
284
+ const result = await withTimeout(Promise.all([
285
+ context.session.get({ sessionID }, { signal: controller.signal }),
286
+ context.session.context({ sessionID }, { signal: controller.signal }),
287
+ ]), LOOKUP_TIMEOUT_MS)
288
+ if (!result || !isRootV2Session(result[0], sessionID)) return undefined
289
+ return findLatestV2HumanTurn(result[1])
290
+ } catch {
291
+ return undefined
292
+ } finally {
293
+ controller.abort()
294
+ }
295
+ }
296
+
297
+ try {
298
+ for (const providerID of MERIDIAN_PROVIDERS) {
299
+ registered.push(await context.session.hook("model.request", async (input) => {
300
+ // Apply lineage/tier identity early, but never emit the routing
301
+ // attestation before the final HTTP boundary.
302
+ await apply(input, input.headers)
303
+ }, { providerID }))
304
+ registered.push(await context.session.hook("http.request", async (input) => {
305
+ // Re-read exact visibility/mode at the final boundary. The model-hook
306
+ // cache is capability-only and must never authorize a stale visible
307
+ // primary after a config reload.
308
+ const metadata = await apply(input, input.request.headers, true)
309
+ if (!metadata) return
310
+ const humanTurn = await resolveHumanTurn(input, input.request.headers, metadata)
311
+ if (!humanTurn) return
312
+ const token = createPriorityAttestation({
313
+ generation: "oc2b18314",
314
+ sessionId: String(input.sessionID),
315
+ agentId: String(input.agent),
316
+ humanMessageId: humanTurn.id,
317
+ issuedAt: humanTurn.issuedAt,
318
+ })
319
+ if (token) setHeader(input.request.headers, PRIORITY_ATTESTATION_HEADER, token)
320
+ }, { providerID }))
321
+ }
322
+ } catch (error) {
323
+ await Promise.allSettled(registered.map(({ dispose }) => dispose()))
324
+ throw error
325
+ }
326
+
327
+ return async () => {
328
+ const results = await Promise.allSettled(registered.map(({ dispose }) => dispose()))
329
+ const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : [])
330
+ if (failures.length > 0) throw new AggregateError(failures, "Failed to dispose Meridian V2 hooks")
331
+ }
332
+ },
333
+ })
334
+
335
+ export default MeridianV2Plugin
@@ -14,10 +14,19 @@
14
14
  * { "plugin": ["/absolute/path/to/plugin/meridian.ts"] }
15
15
  */
16
16
 
17
- type AgentInput = string | { name?: string; mode?: string }
17
+ import {
18
+ PRIORITY_ATTESTATION_HEADER,
19
+ createPriorityAttestation,
20
+ deleteHeader,
21
+ getHeader,
22
+ setHeader,
23
+ } from "./priority-attestation"
24
+
25
+ type AgentInput = string | { name?: string; mode?: string; hidden?: boolean }
26
+ type ConfigAgent = { mode?: string; hidden?: boolean }
18
27
 
19
28
  type Plugin = (input: any) => Promise<{
20
- config?: (cfg: { agent?: Record<string, { mode?: string } | undefined> }) => Promise<void> | void
29
+ config?: (cfg: { agent?: Record<string, ConfigAgent | undefined> }) => Promise<void> | void
21
30
  "chat.headers"?: (
22
31
  input: {
23
32
  sessionID: string
@@ -26,7 +35,7 @@ type Plugin = (input: any) => Promise<{
26
35
  // passes just the agent NAME as a string. Handle both.
27
36
  agent: AgentInput
28
37
  model: { providerID: string }
29
- message: { id: string }
38
+ message: { id: string; sessionID?: string; time?: { created?: number } }
30
39
  },
31
40
  output: { headers: Record<string, string> }
32
41
  ) => Promise<void>
@@ -50,10 +59,33 @@ const BUILTIN_AGENT_MODES: Record<string, string> = {
50
59
  compaction: "subagent",
51
60
  }
52
61
 
53
- const MeridianPlugin: Plugin = async () => {
54
- // Modes from the merged config, per plugin instance. Replaced wholesale on
55
- // every config-hook fire so a reload can't leave stale entries behind.
56
- let configModes: Record<string, string> = {}
62
+ const INTERNAL_AGENT_IDS = new Set(["title", "summary", "compaction"])
63
+ const ROOT_SESSION_CACHE_MAX = 256
64
+ const ROOT_SESSION_CACHE_TTL_MS = 5_000
65
+ const LOOKUP_TIMEOUT_MS = 250
66
+
67
+ function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value)
69
+ }
70
+
71
+ async function withTimeout<T>(pending: Promise<T>, timeoutMs: number): Promise<T | undefined> {
72
+ let timer: ReturnType<typeof setTimeout> | undefined
73
+ const timedOut = new Promise<undefined>((resolve) => {
74
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
75
+ timer.unref?.()
76
+ })
77
+ try {
78
+ return await Promise.race([pending, timedOut])
79
+ } finally {
80
+ if (timer) clearTimeout(timer)
81
+ }
82
+ }
83
+
84
+ const MeridianPlugin: Plugin = async (pluginInput) => {
85
+ // Agent traits from the merged config, per plugin instance. Replaced
86
+ // wholesale on reload so routing eligibility cannot use stale visibility.
87
+ let configAgents: Record<string, ConfigAgent> = {}
88
+ const rootSessionCache = new Map<string, { expiresAt: number; pending: Promise<boolean> }>()
57
89
 
58
90
  const resolve = (agent: AgentInput): { name: string; mode: string } => {
59
91
  if (typeof agent === "object" && agent !== null) {
@@ -63,38 +95,111 @@ const MeridianPlugin: Plugin = async () => {
63
95
  // OpenCode >= 1.17: agent is the name string. Resolve the mode from the
64
96
  // merged config (captured in the config hook) + built-in defaults.
65
97
  const name = String(agent)
66
- return { name, mode: configModes[name] ?? BUILTIN_AGENT_MODES[name] ?? "primary" }
98
+ return { name, mode: configAgents[name]?.mode ?? BUILTIN_AGENT_MODES[name] ?? "primary" }
99
+ }
100
+
101
+ const isStrictVisiblePrimary = (agent: AgentInput): boolean => {
102
+ const name = typeof agent === "string" ? agent : agent.name
103
+ if (!name || INTERNAL_AGENT_IDS.has(name)) return false
104
+ if (typeof agent === "object") {
105
+ return agent.mode === "primary" && agent.hidden !== true
106
+ }
107
+ const configured = configAgents[name]
108
+ if (configured) return configured.mode === "primary" && configured.hidden !== true
109
+ return BUILTIN_AGENT_MODES[name] === "primary"
110
+ }
111
+
112
+ const isRootSession = async (sessionID: string): Promise<boolean> => {
113
+ const cached = rootSessionCache.get(sessionID)
114
+ if (cached && cached.expiresAt > Date.now()) return cached.pending
115
+ const pending = (async (): Promise<boolean> => {
116
+ if (!isRecord(pluginInput) || !isRecord(pluginInput.client)) return false
117
+ const sessionApi = pluginInput.client.session
118
+ if (!isRecord(sessionApi) || typeof sessionApi.get !== "function") return false
119
+ const controller = new AbortController()
120
+ try {
121
+ const lookup = Promise.resolve(Reflect.apply(sessionApi.get, sessionApi, [
122
+ { path: { id: sessionID }, signal: controller.signal },
123
+ ]) as unknown)
124
+ const result = await withTimeout(lookup, LOOKUP_TIMEOUT_MS)
125
+ if (!isRecord(result)) return false
126
+ const data = isRecord(result.data) ? result.data : result
127
+ return data.id === sessionID && data.parentID === undefined && data.fork === undefined
128
+ } catch {
129
+ return false
130
+ } finally {
131
+ controller.abort()
132
+ }
133
+ })()
134
+ rootSessionCache.delete(sessionID)
135
+ rootSessionCache.set(sessionID, { expiresAt: Date.now() + ROOT_SESSION_CACHE_TTL_MS, pending })
136
+ while (rootSessionCache.size > ROOT_SESSION_CACHE_MAX) {
137
+ const oldest = rootSessionCache.keys().next().value
138
+ if (oldest === undefined) break
139
+ rootSessionCache.delete(oldest)
140
+ }
141
+ const eligible = await pending
142
+ if (!eligible && rootSessionCache.get(sessionID)?.pending === pending) rootSessionCache.delete(sessionID)
143
+ return eligible
67
144
  }
68
145
 
69
146
  return {
70
147
  // Runs with the merged OpenCode config (on init, and again on config
71
- // reload). Captures the mode of user-defined agents and built-in
72
- // overrides so chat.headers can classify string agent names.
148
+ // reload). Captures user-defined agents and built-in overrides.
73
149
  config: (cfg) => {
74
- const next: Record<string, string> = {}
150
+ const next: Record<string, ConfigAgent> = {}
75
151
  for (const [name, def] of Object.entries(cfg?.agent ?? {})) {
76
- if (typeof def?.mode === "string") next[name] = def.mode
152
+ if (!def) continue
153
+ next[name] = {
154
+ ...(typeof def.mode === "string" ? { mode: def.mode } : {}),
155
+ ...(typeof def.hidden === "boolean" ? { hidden: def.hidden } : {}),
156
+ }
77
157
  }
78
- configModes = next
158
+ configAgents = next
79
159
  },
80
160
 
81
161
  "chat.headers": async (incoming, output) => {
82
- // Only inject headers for Anthropic provider requests
162
+ // This is V1's final supported outbound boundary. Remove an earlier
163
+ // plugin/provider spoof even when this request is not eligible.
164
+ deleteHeader(output.headers, PRIORITY_ATTESTATION_HEADER)
165
+ // Only inject headers for Anthropic provider requests.
83
166
  if (incoming.model.providerID !== "anthropic") return
84
167
 
85
- // Session tracking
86
- output.headers["x-opencode-session"] = incoming.sessionID
87
- output.headers["x-opencode-request"] = incoming.message.id
168
+ // Session tracking. Replace case-insensitively so the values compared by
169
+ // the proxy are the same trusted hook inputs that are signed below.
170
+ setHeader(output.headers, "x-opencode-session", incoming.sessionID)
171
+ setHeader(output.headers, "x-opencode-request", incoming.message.id)
88
172
 
89
173
  const { name, mode } = resolve(incoming.agent)
174
+ const safeName = name.replace(/[^\x20-\x7E]/g, "").trim() || "unknown"
90
175
 
91
176
  // The proxy expects primary|subagent. "all" agents can act as either;
92
177
  // without per-request context, treat them as primary (full tier) to
93
- // preserve capability.
94
- output.headers["x-opencode-agent-mode"] = mode === "subagent" ? "subagent" : "primary"
178
+ // preserve capability. This permissive tiering decision is NOT reused as
179
+ // strict routing eligibility.
180
+ setHeader(output.headers, "x-opencode-agent-mode", mode === "subagent" ? "subagent" : "primary")
95
181
  // Strip non-ASCII characters (e.g. zero-width spaces) that cause
96
182
  // "Header has invalid value" errors in Node.js / undici.
97
- output.headers["x-opencode-agent-name"] = name.replace(/[^\x20-\x7E]/g, "").trim() || "unknown"
183
+ setHeader(output.headers, "x-opencode-agent-name", safeName)
184
+
185
+ if (getHeader(output.headers, "x-meridian-profile") !== undefined) return
186
+ if (safeName !== name || !isStrictVisiblePrimary(incoming.agent)) return
187
+ const createdAt = incoming.message.time?.created
188
+ if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0) return
189
+ const issuedAt = Math.floor(createdAt / 1000)
190
+ if (incoming.message.sessionID !== undefined && incoming.message.sessionID !== incoming.sessionID) return
191
+ if (!(await isRootSession(incoming.sessionID))) return
192
+ // A config reload can complete while the bounded root lookup is in
193
+ // flight. Re-check visibility at the final signing instant.
194
+ if (!isStrictVisiblePrimary(incoming.agent)) return
195
+ const token = createPriorityAttestation({
196
+ generation: "oc1",
197
+ sessionId: incoming.sessionID,
198
+ agentId: safeName,
199
+ humanMessageId: incoming.message.id,
200
+ issuedAt,
201
+ })
202
+ if (token) setHeader(output.headers, PRIORITY_ATTESTATION_HEADER, token)
98
203
  },
99
204
  }
100
205
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * OpenCode-side signer for Meridian priority failback attestations.
3
+ *
4
+ * This file lives under plugin/ because the V1 plugin is shipped as TypeScript.
5
+ * The V2 build bundles it into dist/meridian-v2.js.
6
+ */
7
+
8
+ import { createHash, createHmac } from "node:crypto"
9
+ import { readFileSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+ import { join } from "node:path"
12
+
13
+ export const PRIORITY_ATTESTATION_HEADER = "x-meridian-opencode-turn"
14
+ export const PRIORITY_ATTESTATION_KEY_ENV = "MERIDIAN_OPENCODE_ATTESTATION_KEY"
15
+ export const PRIORITY_ATTESTATION_KEY_FILE = "opencode-turn.key"
16
+
17
+ const TOKEN_PREFIX = "v1"
18
+ const MAC_DOMAIN = "meridian.opencode.turn.v1\0"
19
+ const TURN_DOMAIN = "meridian.opencode.human.v1\0"
20
+ const MAX_HEADER_BYTES = 768
21
+ const MAX_PAYLOAD_BYTES = 384
22
+ const TURN_DIGEST_PATTERN = /^[A-Za-z0-9_-]{43}$/
23
+ const SAFE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
24
+ export type OpenCodeAttestationGeneration = "oc1" | "oc2b18314"
25
+
26
+ export interface PriorityAttestationSignInput {
27
+ readonly generation: OpenCodeAttestationGeneration
28
+ readonly sessionId: string
29
+ readonly agentId: string
30
+ readonly humanMessageId: string
31
+ /** Immutable OpenCode user-message creation time, in whole Unix seconds. */
32
+ readonly issuedAt: number
33
+ }
34
+
35
+ function configDirectory(): string {
36
+ return process.env.MERIDIAN_CONFIG_DIR ?? join(homedir(), ".config", "meridian")
37
+ }
38
+
39
+ export function priorityAttestationKeyPath(): string {
40
+ return join(configDirectory(), PRIORITY_ATTESTATION_KEY_FILE)
41
+ }
42
+
43
+ function isSafeAgentId(value: string): boolean {
44
+ return value.length >= 1
45
+ && value.length <= 64
46
+ && value.trim() === value
47
+ && /^[\x20-\x7E]+$/.test(value)
48
+ }
49
+
50
+ function decodeCanonicalBase64Url(raw: string): Buffer | undefined {
51
+ if (!/^[A-Za-z0-9_-]+$/.test(raw)) return undefined
52
+ const decoded = Buffer.from(raw, "base64url")
53
+ return decoded.toString("base64url") === raw ? decoded : undefined
54
+ }
55
+
56
+ export function decodePriorityAttestationKey(raw: string | undefined): Buffer | undefined {
57
+ if (raw === undefined) return undefined
58
+ const normalized = raw.trim()
59
+ const decoded = decodeCanonicalBase64Url(normalized)
60
+ return decoded?.length === 32 ? decoded : undefined
61
+ }
62
+
63
+ export function loadPriorityAttestationKey(): Buffer | undefined {
64
+ const fromEnv = process.env[PRIORITY_ATTESTATION_KEY_ENV]
65
+ if (fromEnv !== undefined) return decodePriorityAttestationKey(fromEnv)
66
+ try {
67
+ return decodePriorityAttestationKey(readFileSync(priorityAttestationKeyPath(), "utf8"))
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ export function computePriorityTurnDigest(input: {
74
+ readonly generation: OpenCodeAttestationGeneration
75
+ readonly sessionId: string
76
+ readonly humanMessageId: string
77
+ }): string | undefined {
78
+ if (!SAFE_ID_PATTERN.test(input.sessionId) || !SAFE_ID_PATTERN.test(input.humanMessageId)) {
79
+ return undefined
80
+ }
81
+ return createHash("sha256")
82
+ .update(TURN_DOMAIN)
83
+ .update(input.generation)
84
+ .update("\0")
85
+ .update(input.sessionId)
86
+ .update("\0")
87
+ .update(input.humanMessageId)
88
+ .digest("base64url")
89
+ }
90
+
91
+
92
+
93
+ export function createPriorityAttestation(
94
+ input: PriorityAttestationSignInput,
95
+ key: Buffer = loadPriorityAttestationKey() ?? Buffer.alloc(0),
96
+ ): string | undefined {
97
+ if (key.length !== 32) return undefined
98
+ if (!SAFE_ID_PATTERN.test(input.sessionId) || !isSafeAgentId(input.agentId)) return undefined
99
+ const turnDigest = computePriorityTurnDigest(input)
100
+ if (!turnDigest || !TURN_DIGEST_PATTERN.test(turnDigest)) return undefined
101
+ const issuedAt = input.issuedAt
102
+ if (!Number.isSafeInteger(issuedAt) || issuedAt < 0) return undefined
103
+
104
+ // Fixed insertion order is part of the wire contract. The proxy reconstructs
105
+ // this exact JSON and rejects non-canonical or extensible payloads.
106
+ const payload = JSON.stringify({
107
+ v: 1,
108
+ g: input.generation,
109
+ s: input.sessionId,
110
+ a: input.agentId,
111
+ t: turnDigest,
112
+ iat: issuedAt,
113
+ })
114
+ if (Buffer.byteLength(payload) > MAX_PAYLOAD_BYTES) return undefined
115
+ const encodedPayload = Buffer.from(payload).toString("base64url")
116
+ const mac = createHmac("sha256", key)
117
+ .update(MAC_DOMAIN)
118
+ .update(payload)
119
+ .digest("base64url")
120
+ const token = `${TOKEN_PREFIX}.${encodedPayload}.${mac}`
121
+ return Buffer.byteLength(token) <= MAX_HEADER_BYTES ? token : undefined
122
+ }
123
+
124
+ export type MutableHeaders = Record<string, string> | Headers
125
+
126
+ export function deleteHeader(headers: MutableHeaders, name: string): void {
127
+ if (headers instanceof Headers) {
128
+ headers.delete(name)
129
+ return
130
+ }
131
+ const lower = name.toLowerCase()
132
+ for (const key of Object.keys(headers)) {
133
+ if (key.toLowerCase() === lower) delete headers[key]
134
+ }
135
+ }
136
+
137
+ export function getHeader(headers: MutableHeaders, name: string): string | undefined {
138
+ if (headers instanceof Headers) return headers.get(name) ?? undefined
139
+ const lower = name.toLowerCase()
140
+ for (const [key, value] of Object.entries(headers)) {
141
+ if (key.toLowerCase() === lower) return value
142
+ }
143
+ return undefined
144
+ }
145
+
146
+ export function setHeader(headers: MutableHeaders, name: string, value: string): void {
147
+ deleteHeader(headers, name)
148
+ if (headers instanceof Headers) headers.set(name, value)
149
+ else headers[name] = value
150
+ }