@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.
- package/dist/{cli-4ghkaajs.js → cli-7cts44b5.js} +1167 -67
- package/dist/{cli-pbvm9kc2.js → cli-n8t34zmq.js} +203 -24
- package/dist/{cli-m0p2bc8v.js → cli-pdpry6q0.js} +16 -3
- package/dist/cli.js +6 -6
- package/dist/meridian-v2.js +241 -33
- 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/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.map +1 -1
- package/dist/server.js +3 -3
- package/dist/{setup-4v3zg3c0.js → setup-8fwgwhqh.js} +1 -1
- package/package.json +2 -2
- package/plugin/meridian-v2.ts +185 -40
- package/plugin/meridian.ts +125 -20
- package/plugin/priority-attestation.ts +150 -0
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
|
+
}
|