@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.
- package/README.md +3 -2
- package/dist/{cli-hdyptm7z.js → cli-7cts44b5.js} +15672 -4267
- package/dist/cli-n8t34zmq.js +939 -0
- package/dist/{cli-m0p2bc8v.js → cli-pdpry6q0.js} +16 -3
- package/dist/cli.js +72 -13
- package/dist/meridian-v2.js +347 -0
- package/dist/{profileCli-39vshwdn.js → profileCli-ap7eg985.js} +2 -2
- package/dist/{profiles-0pyeqayw.js → profiles-4ajzjqhm.js} +1 -1
- package/dist/proxy/adapter.d.ts +16 -0
- package/dist/proxy/adapter.d.ts.map +1 -1
- package/dist/proxy/adapters/opencode.d.ts.map +1 -1
- package/dist/proxy/errors.d.ts.map +1 -1
- package/dist/proxy/passthroughTools.d.ts.map +1 -1
- package/dist/proxy/priorityAttestation.d.ts +35 -0
- package/dist/proxy/priorityAttestation.d.ts.map +1 -0
- package/dist/proxy/routing.d.ts +16 -2
- package/dist/proxy/routing.d.ts.map +1 -1
- package/dist/proxy/server.d.ts.map +1 -1
- package/dist/proxy/session/cache.d.ts +25 -2
- package/dist/proxy/session/cache.d.ts.map +1 -1
- package/dist/proxy/session/lineage.d.ts +1 -1
- package/dist/proxy/session/lineage.d.ts.map +1 -1
- package/dist/proxy/session/turnCoordinator.d.ts +2 -0
- package/dist/proxy/session/turnCoordinator.d.ts.map +1 -1
- package/dist/proxy/sessionStore.d.ts +133 -0
- package/dist/proxy/sessionStore.d.ts.map +1 -1
- package/dist/proxy/settings.d.ts +2 -0
- package/dist/proxy/settings.d.ts.map +1 -1
- package/dist/proxy/setup.d.ts +35 -10
- package/dist/proxy/setup.d.ts.map +1 -1
- package/dist/server.js +5 -5
- package/dist/setup-8fwgwhqh.js +33 -0
- package/package.json +9 -5
- package/plugin/meridian-v2.ts +335 -0
- package/plugin/meridian.ts +125 -20
- package/plugin/priority-attestation.ts +150 -0
- package/dist/cli-pc0mtjjv.js +0 -167
- package/dist/setup-0x573t61.js +0 -19
- 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
|
package/plugin/meridian.ts
CHANGED
|
@@ -14,10 +14,19 @@
|
|
|
14
14
|
* { "plugin": ["/absolute/path/to/plugin/meridian.ts"] }
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
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,
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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
|
|
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,
|
|
150
|
+
const next: Record<string, ConfigAgent> = {}
|
|
75
151
|
for (const [name, def] of Object.entries(cfg?.agent ?? {})) {
|
|
76
|
-
if (
|
|
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
|
-
|
|
158
|
+
configAgents = next
|
|
79
159
|
},
|
|
80
160
|
|
|
81
161
|
"chat.headers": async (incoming, output) => {
|
|
82
|
-
//
|
|
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
|
-
|
|
87
|
-
output.headers
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|