@rynfar/meridian 1.64.0 → 1.65.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.
@@ -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
+ }