@openviking/opencode-plugin 2026.9.10 → 2026.9.21
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/INSTALL-ZH.md +42 -28
- package/INSTALL.md +41 -27
- package/README.md +47 -33
- package/index.mjs +5 -9
- package/lib/config.mjs +30 -307
- package/lib/memory-recall.mjs +2 -2
- package/lib/memory-session.mjs +6 -5
- package/lib/repo-context.mjs +2 -3
- package/lib/runtime.mjs +12 -19
- package/lib/shared/capture-utils.mjs +77 -1
- package/lib/shared/config-schema.mjs +359 -0
- package/lib/shared/credentials.mjs +238 -102
- package/lib/shared/mcp-proxy-config.mjs +154 -2
- package/lib/shared/mcp-proxy-core.mjs +23 -15
- package/lib/shared/ov-http.mjs +112 -0
- package/lib/shared/plugin-config.mjs +300 -0
- package/lib/shared/recall-core.mjs +66 -13
- package/lib/shared/uri-guard.mjs +173 -1
- package/lib/shared/workspace-config.mjs +433 -0
- package/lib/shared/workspace-peer.mjs +63 -0
- package/lib/shared/workspace-registry.mjs +87 -0
- package/lib/utils.mjs +34 -177
- package/lib/viking-uri-guard.mjs +23 -8
- package/package.json +4 -3
- package/servers/mcp-proxy.mjs +7 -27
- package/servers/mcp-proxy.test.mjs +0 -102
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join, resolve as resolvePath } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
5
|
|
|
7
6
|
const DEFAULT_OVCLI_CONF_PATH = join(homedir(), ".openviking", "ovcli.conf");
|
|
8
7
|
const DEFAULT_OV_CONF_PATH = join(homedir(), ".openviking", "ov.conf");
|
|
9
|
-
const
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
9
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
10
10
|
|
|
11
11
|
function str(val, fallback = "") {
|
|
12
12
|
if (typeof val === "string" && val.trim()) return val.trim();
|
|
@@ -108,6 +108,88 @@ export function loadCredentialFiles(env = process.env) {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The environment variables that name part of the connection. Any one of them
|
|
113
|
+
* set is what takes the chain off ovcli.conf in `auto` mode, so a variable
|
|
114
|
+
* that only tunes how the chain runs must not be on this list: an
|
|
115
|
+
* `OPENVIKING_AUTH_MODE` exported on its own would otherwise unpin the file.
|
|
116
|
+
*/
|
|
117
|
+
export const CREDENTIAL_ENV_VARS = [
|
|
118
|
+
"OPENVIKING_URL",
|
|
119
|
+
"OPENVIKING_BASE_URL",
|
|
120
|
+
"OPENVIKING_MCP_URL",
|
|
121
|
+
"OPENVIKING_BEARER_TOKEN",
|
|
122
|
+
"OPENVIKING_API_KEY",
|
|
123
|
+
"OPENVIKING_ACCOUNT",
|
|
124
|
+
"OPENVIKING_USER",
|
|
125
|
+
"OPENVIKING_PEER_ID",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Every environment variable `resolveConnection` reads. */
|
|
129
|
+
export const CONNECTION_ENV_VARS = [
|
|
130
|
+
...CREDENTIAL_ENV_VARS,
|
|
131
|
+
"OPENVIKING_AUTH_MODE",
|
|
132
|
+
"OPENVIKING_CREDENTIAL_SOURCE",
|
|
133
|
+
"OPENVIKING_CREDENTIALS_SOURCE",
|
|
134
|
+
"OPENVIKING_CLI_CONFIG_FILE",
|
|
135
|
+
"OPENVIKING_CONFIG_FILE",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
function isSection(value) {
|
|
139
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* ov.conf spells its sections snake_case while a host may call itself
|
|
144
|
+
* `claude-code` or `trae-cn`, so both spellings land on the same block. The
|
|
145
|
+
* normalization stays inline rather than importing `config-schema.mjs`, which
|
|
146
|
+
* the portable agent-plugins bundle would then have to ship as well.
|
|
147
|
+
*/
|
|
148
|
+
function sectionKey(harness) {
|
|
149
|
+
return String(harness || "").trim().toLowerCase().replace(/-/g, "_");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The calling harness's own section of ov.conf. */
|
|
153
|
+
function harnessSection(ovFile, harness) {
|
|
154
|
+
const section = ovFile[sectionKey(harness)];
|
|
155
|
+
return isSection(section) ? section : {};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* ovcli.conf's `plugin` section as this harness sees it: the shared scalars,
|
|
160
|
+
* then `plugin.<harness>` over them in both spellings, the hyphenated one last.
|
|
161
|
+
* `loadPluginSettings` merges the same way for every other knob, and the two
|
|
162
|
+
* must agree or a key would win in the hooks and lose here.
|
|
163
|
+
*/
|
|
164
|
+
function pluginSection(cliFile, harness) {
|
|
165
|
+
const plugin = isSection(cliFile.plugin) ? cliFile.plugin : {};
|
|
166
|
+
const merged = {};
|
|
167
|
+
for (const [key, value] of Object.entries(plugin)) {
|
|
168
|
+
if (!isSection(value)) merged[key] = value;
|
|
169
|
+
}
|
|
170
|
+
const key = sectionKey(harness);
|
|
171
|
+
for (const candidate of [key, key.replace(/_/g, "-")]) {
|
|
172
|
+
if (candidate && isSection(plugin[candidate])) Object.assign(merged, plugin[candidate]);
|
|
173
|
+
}
|
|
174
|
+
return merged;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A knob-file scalar as the schema coerces a string knob: `12345` is "12345". */
|
|
178
|
+
function scalar(value) {
|
|
179
|
+
return value === undefined || value === null ? "" : String(value).trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const AUTH_MODES = ["trusted", "api_key"];
|
|
183
|
+
|
|
184
|
+
function normalizeAuthMode(value) {
|
|
185
|
+
const mode = str(value, "").toLowerCase();
|
|
186
|
+
return AUTH_MODES.includes(mode) ? mode : "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function authModeIn(section) {
|
|
190
|
+
return normalizeAuthMode(section.authMode) || normalizeAuthMode(section.auth_mode);
|
|
191
|
+
}
|
|
192
|
+
|
|
111
193
|
function sourceMode(env) {
|
|
112
194
|
const raw = str(env.OPENVIKING_CREDENTIAL_SOURCE, str(env.OPENVIKING_CREDENTIALS_SOURCE, "auto"))
|
|
113
195
|
.toLowerCase();
|
|
@@ -117,123 +199,177 @@ function sourceMode(env) {
|
|
|
117
199
|
}
|
|
118
200
|
|
|
119
201
|
function hasEnvCredentialFields(env) {
|
|
120
|
-
return
|
|
121
|
-
str(env.OPENVIKING_URL, str(env.OPENVIKING_BASE_URL, "")) ||
|
|
122
|
-
str(env.OPENVIKING_MCP_URL, "") ||
|
|
123
|
-
str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, "")) ||
|
|
124
|
-
str(env.OPENVIKING_ACCOUNT, "") ||
|
|
125
|
-
str(env.OPENVIKING_USER, "") ||
|
|
126
|
-
str(env.OPENVIKING_PEER_ID, ""),
|
|
127
|
-
);
|
|
202
|
+
return CREDENTIAL_ENV_VARS.some((name) => str(env[name], ""));
|
|
128
203
|
}
|
|
129
204
|
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (mode !== "cli" && envUrl) return envUrl.replace(/\/+$/, "");
|
|
135
|
-
if (useCli && cliUrl) return cliUrl.replace(/\/+$/, "");
|
|
136
|
-
if (mode !== "env" && cliUrl) return cliUrl.replace(/\/+$/, "");
|
|
137
|
-
|
|
138
|
-
const server = ovFile.server || {};
|
|
139
|
-
const ovUrl = str(server.url, "");
|
|
140
|
-
if (ovUrl) return ovUrl.replace(/\/+$/, "");
|
|
205
|
+
function trimSlash(value) {
|
|
206
|
+
return value.replace(/\/+$/, "");
|
|
207
|
+
}
|
|
141
208
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
209
|
+
/** The first layer that holds a value, with the source label and file it names. */
|
|
210
|
+
function firstLayer(layers) {
|
|
211
|
+
for (const [value, source, path = ""] of layers) {
|
|
212
|
+
if (value) return { value, source, path };
|
|
213
|
+
}
|
|
214
|
+
return { value: "", source: "none", path: "" };
|
|
145
215
|
}
|
|
146
216
|
|
|
147
|
-
|
|
148
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The one connection a harness has: which server, which key, whose identity,
|
|
219
|
+
* and whether that identity goes on the wire. Hooks reach it through
|
|
220
|
+
* `buildPluginConfig`, every MCP proxy through the same loader or through
|
|
221
|
+
* `buildProxyConnection`, so the two sides of one harness cannot disagree.
|
|
222
|
+
*
|
|
223
|
+
* Only three kinds of input count: what an embedding host named (`hostInput`),
|
|
224
|
+
* the environment, and the two files under `~/.openviking`. The working
|
|
225
|
+
* directory never does — an MCP proxy starts wherever its host launches it.
|
|
226
|
+
*
|
|
227
|
+
* ovcli.conf pins the chain when it names a url, key, identity or peer and the
|
|
228
|
+
* environment names none of those; pinned, the environment's credentials no
|
|
229
|
+
* longer apply. `OPENVIKING_CREDENTIAL_SOURCE` forces either side, and forced
|
|
230
|
+
* to `env` no file is read at all — which is what lets a parent process hand a
|
|
231
|
+
* resolved connection, including an empty key, to a child verbatim. Every chain
|
|
232
|
+
* runs host → env (unpinned) → ovcli.conf → its `plugin.<harness>` and
|
|
233
|
+
* `plugin` keys → ov.conf's harness section; the key then ends at
|
|
234
|
+
* `server.root_api_key`, which a pinned chain reaches only with
|
|
235
|
+
* `rootKeyFallback`, and the identity skips ov.conf's harness section when
|
|
236
|
+
* pinned. The auth mode reads the environment in either mode, then the
|
|
237
|
+
* `plugin` keys, the harness section and `server.auth_mode`; failing all of
|
|
238
|
+
* them, an identity from any layer means the deployment expects one.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveConnection(harness, {
|
|
241
|
+
env = process.env,
|
|
242
|
+
files = null,
|
|
243
|
+
hostInput = {},
|
|
244
|
+
rootKeyFallback = false,
|
|
245
|
+
} = {}) {
|
|
246
|
+
const name = str(harness, "");
|
|
247
|
+
const loaded = files || loadCredentialFiles(env);
|
|
248
|
+
const { cliFile, ovFile, cliPath, ovPath } = loaded;
|
|
249
|
+
const host = hostInput || {};
|
|
149
250
|
const mode = sourceMode(env);
|
|
150
251
|
const envHasCredentials = hasEnvCredentialFields(env);
|
|
151
|
-
const
|
|
152
|
-
(mode === "auto" && !envHasCredentials &&
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
let credentialPath = "";
|
|
198
|
-
if (apiKey) {
|
|
199
|
-
if (useCli) credentialPath = files.cliPath;
|
|
200
|
-
else if (str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, ""))) credentialPath = "";
|
|
201
|
-
else if (str(files.cliFile.api_key, "")) credentialPath = files.cliPath;
|
|
202
|
-
else credentialPath = files.ovPath;
|
|
203
|
-
}
|
|
252
|
+
const pinned = mode === "cli" ||
|
|
253
|
+
(mode === "auto" && !envHasCredentials && Boolean(cliPath) && hasCredentialFields(cliFile));
|
|
254
|
+
const unpinned = (layer) => (pinned ? [] : [layer]);
|
|
255
|
+
const fromFiles = mode !== "env";
|
|
256
|
+
const filed = (...layers) => (fromFiles ? layers : []);
|
|
257
|
+
const plugin = pluginSection(cliFile, name);
|
|
258
|
+
const section = harnessSection(ovFile, name);
|
|
259
|
+
const server = isSection(ovFile.server) ? ovFile.server : {};
|
|
260
|
+
|
|
261
|
+
const key = firstLayer([
|
|
262
|
+
[str(host.apiKey, ""), "host"],
|
|
263
|
+
...unpinned([str(env.OPENVIKING_BEARER_TOKEN, str(env.OPENVIKING_API_KEY, "")), "env"]),
|
|
264
|
+
...filed(
|
|
265
|
+
[str(cliFile.api_key, ""), "ovcli", cliPath],
|
|
266
|
+
[scalar(plugin.apiKey), "ovcli", cliPath],
|
|
267
|
+
[scalar(section.apiKey), "ov", ovPath],
|
|
268
|
+
),
|
|
269
|
+
...((pinned && !rootKeyFallback) ? [] : filed([str(server.root_api_key, ""), "ov", ovPath])),
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
const identity = (hostValue, envName, cliValue, knob) => firstLayer([
|
|
273
|
+
[str(hostValue, ""), "host"],
|
|
274
|
+
...unpinned([str(env[envName], ""), "env"]),
|
|
275
|
+
...filed([cliValue, "ovcli", cliPath], [scalar(plugin[knob]), "ovcli", cliPath]),
|
|
276
|
+
...(pinned ? [] : filed([str(section[knob], ""), "ov", ovPath])),
|
|
277
|
+
]).value;
|
|
278
|
+
const account = identity(host.account, "OPENVIKING_ACCOUNT", str(cliFile.account, str(cliFile.account_id, "")), "accountId");
|
|
279
|
+
const user = identity(host.user, "OPENVIKING_USER", str(cliFile.user, str(cliFile.user_id, "")), "userId");
|
|
280
|
+
|
|
281
|
+
const peerId = firstLayer([
|
|
282
|
+
...unpinned([str(env.OPENVIKING_PEER_ID, ""), "env"]),
|
|
283
|
+
...filed([str(cliFile.actor_peer_id, str(cliFile.peer_id, "")), "ovcli"]),
|
|
284
|
+
...(pinned ? [] : filed([str(section.peerId, str(section.peer_id, "")), "ov"])),
|
|
285
|
+
]).value;
|
|
286
|
+
|
|
287
|
+
const authMode = normalizeAuthMode(host.authMode)
|
|
288
|
+
|| normalizeAuthMode(env.OPENVIKING_AUTH_MODE)
|
|
289
|
+
|| (fromFiles && (authModeIn(plugin) || authModeIn(section) || normalizeAuthMode(server.auth_mode)))
|
|
290
|
+
|| ((account || user) ? "trusted" : "api_key");
|
|
291
|
+
|
|
292
|
+
const hostBaseUrl = trimSlash(str(host.baseUrl, ""));
|
|
293
|
+
const envUrl = pinned ? "" : str(env.OPENVIKING_URL, str(env.OPENVIKING_BASE_URL, ""));
|
|
294
|
+
const fileUrl = fromFiles ? (str(cliFile.url, "") || str(server.url, "")) : "";
|
|
295
|
+
const baseUrl = hostBaseUrl || trimSlash(envUrl || fileUrl) || serverAddress(fromFiles ? server : {});
|
|
296
|
+
const envMcpUrl = pinned ? "" : str(env.OPENVIKING_MCP_URL, "");
|
|
297
|
+
const mcpUrl = (!hostBaseUrl && envMcpUrl) ? envMcpUrl : `${baseUrl}/mcp`;
|
|
204
298
|
|
|
205
299
|
return {
|
|
206
|
-
...
|
|
207
|
-
|
|
208
|
-
|
|
300
|
+
...loaded,
|
|
301
|
+
harness: name,
|
|
302
|
+
credentialSource: pinned ? "ovcli" : ((mode === "env" || envHasCredentials) ? "env" : "auto"),
|
|
209
303
|
baseUrl,
|
|
210
304
|
mcpUrl,
|
|
211
|
-
apiKey,
|
|
305
|
+
apiKey: key.value,
|
|
212
306
|
account,
|
|
213
307
|
user,
|
|
214
308
|
peerId,
|
|
215
|
-
|
|
309
|
+
authMode,
|
|
310
|
+
sendIdentityHeaders: authMode === "trusted",
|
|
311
|
+
hasApiKey: Boolean(key.value),
|
|
312
|
+
// Which layer supplied the key and the file behind it, empty when the key
|
|
313
|
+
// came from the host or the environment. A doctor and a 401 hint report it;
|
|
314
|
+
// `credentialSource` is the mode the chain ran in, not a file.
|
|
315
|
+
apiKeySource: key.source,
|
|
316
|
+
credentialPath: key.path,
|
|
216
317
|
};
|
|
217
318
|
}
|
|
218
319
|
|
|
219
|
-
function
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
if (cmd === "has-peer-id") {
|
|
230
|
-
process.stdout.write(resolveOpenVikingCredentials().peerId ? "1" : "0");
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
process.stderr.write("usage: ov-credentials.mjs <mcp-url|has-api-key|has-peer-id>\n");
|
|
234
|
-
process.exitCode = 2;
|
|
320
|
+
function serverAddress(server) {
|
|
321
|
+
const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1");
|
|
322
|
+
const port = Number.isFinite(Number(server.port)) ? Math.floor(Number(server.port)) : 1933;
|
|
323
|
+
return `http://${host}:${port}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function envFlag(env, name) {
|
|
327
|
+
const raw = str(env[name], "").toLowerCase();
|
|
328
|
+
return raw === "1" || raw === "true" || raw === "yes";
|
|
235
329
|
}
|
|
236
330
|
|
|
237
|
-
|
|
238
|
-
|
|
331
|
+
function clampTimeout(value) {
|
|
332
|
+
const raw = str(value, "");
|
|
333
|
+
const parsed = raw ? Number(raw) : NaN;
|
|
334
|
+
return Math.max(MIN_TIMEOUT_MS, Math.floor(Number.isFinite(parsed) ? parsed : DEFAULT_TIMEOUT_MS));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Everything a stdio MCP proxy needs, for a package that ships no hooks.
|
|
339
|
+
*
|
|
340
|
+
* `buildPluginConfig` answers the same question one layer up, but reaching it
|
|
341
|
+
* means vendoring the knob schema and the workspace layers — a closure the
|
|
342
|
+
* portable agent-plugins bundle would carry in git to send one HTTP header.
|
|
343
|
+
* This is the same connection plus what a proxy adds to it: the User-Agent,
|
|
344
|
+
* the request timeout, and the debug log.
|
|
345
|
+
*
|
|
346
|
+
* The api_key ends at `server.root_api_key` even when ovcli.conf pinned the
|
|
347
|
+
* chain to itself. A portable package has no installer to migrate anyone, so
|
|
348
|
+
* an install that names only a `url` there keeps the key it has always used.
|
|
349
|
+
*/
|
|
350
|
+
export function buildProxyConnection(harness, { env = process.env, manifestUrl = "", version = "" } = {}) {
|
|
351
|
+
const name = str(harness);
|
|
352
|
+
const connection = resolveConnection(name, { env, rootKeyFallback: true });
|
|
353
|
+
return {
|
|
354
|
+
harness: name,
|
|
355
|
+
userAgent: buildUserAgent(name, str(version) || (manifestUrl ? readManifestVersion(manifestUrl) : "")),
|
|
356
|
+
baseUrl: connection.baseUrl,
|
|
357
|
+
mcpUrl: connection.mcpUrl,
|
|
358
|
+
apiKey: connection.apiKey,
|
|
359
|
+
account: connection.account,
|
|
360
|
+
user: connection.user,
|
|
361
|
+
peerId: connection.peerId,
|
|
362
|
+
authMode: connection.authMode,
|
|
363
|
+
sendIdentityHeaders: connection.sendIdentityHeaders,
|
|
364
|
+
credentialSource: connection.credentialSource,
|
|
365
|
+
apiKeySource: connection.apiKeySource,
|
|
366
|
+
credentialPath: connection.credentialPath,
|
|
367
|
+
hasApiKey: connection.hasApiKey,
|
|
368
|
+
cliPath: connection.cliPath,
|
|
369
|
+
ovPath: connection.ovPath,
|
|
370
|
+
timeoutMs: clampTimeout(env.OPENVIKING_TIMEOUT_MS),
|
|
371
|
+
debug: envFlag(env, "OPENVIKING_DEBUG"),
|
|
372
|
+
debugLogPath: str(env.OPENVIKING_DEBUG_LOG)
|
|
373
|
+
|| join(homedir(), ".openviking", "logs", `${name}.log`),
|
|
374
|
+
};
|
|
239
375
|
}
|
|
@@ -12,9 +12,56 @@
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join, resolve as resolvePath } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { CONNECTION_ENV_VARS, CREDENTIAL_ENV_VARS } from "./credentials.mjs";
|
|
16
|
+
|
|
15
17
|
export const DEFAULT_PROXY_TIMEOUT_MS = 15000;
|
|
16
18
|
const MIN_PROXY_TIMEOUT_MS = 1000;
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every variable that changes what an MCP proxy sends. Hooks inherit their
|
|
22
|
+
* host's whole environment; a host that hands its MCP servers an allowlist
|
|
23
|
+
* instead (Codex's `env_vars`) has to name each of these, or the proxy
|
|
24
|
+
* resolves a different connection from the hooks beside it.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_PROXY_ENV_VARS = [
|
|
27
|
+
...CONNECTION_ENV_VARS,
|
|
28
|
+
"OPENVIKING_HOME",
|
|
29
|
+
"OPENVIKING_STATE_DIR",
|
|
30
|
+
"OPENVIKING_RECALL_PEER_SCOPE",
|
|
31
|
+
"OPENVIKING_TIMEOUT_MS",
|
|
32
|
+
"OPENVIKING_DEBUG",
|
|
33
|
+
"OPENVIKING_DEBUG_LOG",
|
|
34
|
+
"OPENVIKING_EXTRA_HEADERS",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A resolved connection as the environment of a child proxy process.
|
|
39
|
+
*
|
|
40
|
+
* The other way across the process boundary, for a host that cannot hand its
|
|
41
|
+
* MCP servers the inputs (DSH drops credential-shaped names from what a child
|
|
42
|
+
* inherits): the parent resolves once and the child reads the answer back. The
|
|
43
|
+
* forced `env` source makes that exact — the child reads no file, so a key the
|
|
44
|
+
* parent left empty stays empty instead of falling through to
|
|
45
|
+
* `server.root_api_key`. Every credential variable is written, the empty ones
|
|
46
|
+
* too, because the rest of the parent's environment still reaches the child:
|
|
47
|
+
* an `OPENVIKING_ACCOUNT` the parent's pinned chain ignored must not fill the
|
|
48
|
+
* gap there.
|
|
49
|
+
*/
|
|
50
|
+
export function forwardConnectionEnv(connection) {
|
|
51
|
+
const env = Object.fromEntries(CREDENTIAL_ENV_VARS.map((name) => [name, ""]));
|
|
52
|
+
return {
|
|
53
|
+
...env,
|
|
54
|
+
OPENVIKING_CREDENTIAL_SOURCE: "env",
|
|
55
|
+
OPENVIKING_URL: connection.baseUrl,
|
|
56
|
+
OPENVIKING_MCP_URL: connection.mcpUrl,
|
|
57
|
+
OPENVIKING_API_KEY: connection.apiKey || "",
|
|
58
|
+
OPENVIKING_ACCOUNT: connection.account || "",
|
|
59
|
+
OPENVIKING_USER: connection.user || "",
|
|
60
|
+
OPENVIKING_PEER_ID: connection.peerId || "",
|
|
61
|
+
OPENVIKING_AUTH_MODE: connection.authMode,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
export function trimSlash(value) {
|
|
19
66
|
return String(value || "").replace(/\/+$/, "");
|
|
20
67
|
}
|
|
@@ -55,7 +102,7 @@ export function defaultCredentialPaths(env = process.env) {
|
|
|
55
102
|
* start: the proxy is what carries every memory tool, and taking those away
|
|
56
103
|
* because a scope preference cannot be honoured costs the user far more than
|
|
57
104
|
* the wider search does. Only the harness whose parent process injects the peer
|
|
58
|
-
* (dsh, `mcp.mjs
|
|
105
|
+
* (dsh, `mcp-env.mjs`) can satisfy this without the user setting it.
|
|
59
106
|
*/
|
|
60
107
|
export function resolveMcpActorPeerId({
|
|
61
108
|
peerId = "",
|
|
@@ -79,6 +126,67 @@ function uniq(values) {
|
|
|
79
126
|
return [...new Set(values.filter(Boolean))];
|
|
80
127
|
}
|
|
81
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Parse `OPENVIKING_EXTRA_HEADERS` into a plain string→string map.
|
|
131
|
+
*
|
|
132
|
+
* Some private deployments sit behind gateways that require custom headers
|
|
133
|
+
* (a tenant/vault name, a signed token, a region hint) on every request. The
|
|
134
|
+
* proxy has no other seam to inject those: the credential file's own
|
|
135
|
+
* `extra_headers` field is intentionally not exported to MCP callers (see
|
|
136
|
+
* `workspace-config.mjs` `FORBIDDEN_KEYS`), so this env-only escape hatch keeps
|
|
137
|
+
* that boundary while letting an operator unblock a strict upstream.
|
|
138
|
+
*
|
|
139
|
+
* The value is a JSON object; anything else is ignored with a stderr warning
|
|
140
|
+
* rather than crashing the proxy — a bad env var must not take down the whole
|
|
141
|
+
* memory tool surface. Reserved names (`Content-Type`, `Accept`,
|
|
142
|
+
* `MCP-Protocol-Version`, `Mcp-Session-Id`, `Authorization`) are dropped: they
|
|
143
|
+
* are set by the proxy itself and letting the env override them would break
|
|
144
|
+
* session negotiation or expose the wrong credential.
|
|
145
|
+
*/
|
|
146
|
+
const RESERVED_HEADER_NAMES = new Set([
|
|
147
|
+
"content-type",
|
|
148
|
+
"accept",
|
|
149
|
+
"mcp-protocol-version",
|
|
150
|
+
"mcp-session-id",
|
|
151
|
+
"authorization",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
export function parseExtraHeaders(raw, { onWarn = null } = {}) {
|
|
155
|
+
const source = String(raw || "").trim();
|
|
156
|
+
if (!source) return {};
|
|
157
|
+
const warn = (message) => {
|
|
158
|
+
if (typeof onWarn === "function") onWarn(message);
|
|
159
|
+
else process.stderr.write(`${message}\n`);
|
|
160
|
+
};
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(source);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
warn(`OPENVIKING_EXTRA_HEADERS is not valid JSON; ignoring (${err.message})`);
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
169
|
+
warn("OPENVIKING_EXTRA_HEADERS must be a JSON object of string values; ignoring");
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
174
|
+
const name = String(key || "").trim();
|
|
175
|
+
if (!name) continue;
|
|
176
|
+
if (RESERVED_HEADER_NAMES.has(name.toLowerCase())) {
|
|
177
|
+
warn(`OPENVIKING_EXTRA_HEADERS: ignoring reserved header "${name}"`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (value === null || value === undefined) continue;
|
|
181
|
+
if (typeof value === "object") {
|
|
182
|
+
warn(`OPENVIKING_EXTRA_HEADERS: header "${name}" must be a scalar; ignoring`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
out[name] = String(value);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
82
190
|
/**
|
|
83
191
|
* Shape a harness's loaded config into the object `createOpenVikingMcpProxy`
|
|
84
192
|
* expects.
|
|
@@ -86,7 +194,9 @@ function uniq(values) {
|
|
|
86
194
|
* `mcpUrl` wins over `baseUrl` when both are given, so a harness that lets the
|
|
87
195
|
* user pin an explicit MCP URL keeps that behavior. `watchedPaths` are the
|
|
88
196
|
* harness's own extra files; the shared defaults are appended and the result
|
|
89
|
-
* deduplicated.
|
|
197
|
+
* deduplicated. `sendIdentityHeaders` carries the harness's resolved auth mode
|
|
198
|
+
* so an `api_key` server, which reads the identity out of the key and ignores
|
|
199
|
+
* the headers, never sees the operator's account and user on the wire.
|
|
90
200
|
*/
|
|
91
201
|
export function buildMcpProxyConfig({
|
|
92
202
|
baseUrl = "",
|
|
@@ -94,6 +204,7 @@ export function buildMcpProxyConfig({
|
|
|
94
204
|
apiKey = "",
|
|
95
205
|
account = "",
|
|
96
206
|
user = "",
|
|
207
|
+
sendIdentityHeaders = false,
|
|
97
208
|
peerId = "",
|
|
98
209
|
userAgent = "",
|
|
99
210
|
timeoutMs,
|
|
@@ -102,13 +213,18 @@ export function buildMcpProxyConfig({
|
|
|
102
213
|
credentialSource = "auto",
|
|
103
214
|
credentialPath = "",
|
|
104
215
|
watchedPaths = [],
|
|
216
|
+
extraHeaders = null,
|
|
105
217
|
env = process.env,
|
|
106
218
|
} = {}) {
|
|
219
|
+
const resolvedExtraHeaders = extraHeaders && typeof extraHeaders === "object" && !Array.isArray(extraHeaders)
|
|
220
|
+
? { ...extraHeaders }
|
|
221
|
+
: parseExtraHeaders(env.OPENVIKING_EXTRA_HEADERS);
|
|
107
222
|
return {
|
|
108
223
|
mcpUrl: mcpUrl || `${trimSlash(baseUrl)}/mcp`,
|
|
109
224
|
apiKey: apiKey || "",
|
|
110
225
|
account: account || "",
|
|
111
226
|
user: user || "",
|
|
227
|
+
sendIdentityHeaders: sendIdentityHeaders === true,
|
|
112
228
|
peerId: peerId || "",
|
|
113
229
|
userAgent: userAgent || "",
|
|
114
230
|
timeoutMs: Math.max(
|
|
@@ -120,5 +236,41 @@ export function buildMcpProxyConfig({
|
|
|
120
236
|
credentialSource: credentialSource || "auto",
|
|
121
237
|
credentialPath: credentialPath || "",
|
|
122
238
|
watchedPaths: uniq([...watchedPaths, ...defaultCredentialPaths(env)]),
|
|
239
|
+
extraHeaders: resolvedExtraHeaders,
|
|
123
240
|
};
|
|
124
241
|
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The proxy config for a resolved harness config, mapped in one place.
|
|
245
|
+
*
|
|
246
|
+
* Every proxy used to copy fields out of its loader by hand, and the copies
|
|
247
|
+
* drifted: two dropped `mcpUrl`, so `OPENVIKING_MCP_URL` reached the hooks and
|
|
248
|
+
* not the tools. `cfg` is what `buildPluginConfig` or `buildProxyConnection`
|
|
249
|
+
* returned. `peerId` replaces the actor peer only for a harness whose parent
|
|
250
|
+
* process resolved it (dsh); everywhere else a long-lived proxy may send one
|
|
251
|
+
* only when actor-scoped recall asks for it.
|
|
252
|
+
*/
|
|
253
|
+
export function toMcpProxyConfig(cfg, {
|
|
254
|
+
env = process.env,
|
|
255
|
+
peerId = undefined,
|
|
256
|
+
debug = cfg.debug,
|
|
257
|
+
debugLogPath = cfg.debugLogPath,
|
|
258
|
+
} = {}) {
|
|
259
|
+
return buildMcpProxyConfig({
|
|
260
|
+
baseUrl: cfg.baseUrl,
|
|
261
|
+
mcpUrl: cfg.mcpUrl,
|
|
262
|
+
apiKey: cfg.apiKey,
|
|
263
|
+
account: cfg.account,
|
|
264
|
+
user: cfg.user,
|
|
265
|
+
sendIdentityHeaders: cfg.sendIdentityHeaders,
|
|
266
|
+
peerId: peerId === undefined ? resolveMcpActorPeerId(cfg) : peerId,
|
|
267
|
+
userAgent: cfg.userAgent,
|
|
268
|
+
timeoutMs: cfg.timeoutMs,
|
|
269
|
+
debug,
|
|
270
|
+
debugLogPath,
|
|
271
|
+
credentialSource: cfg.credentialSource,
|
|
272
|
+
credentialPath: cfg.credentialPath || "",
|
|
273
|
+
watchedPaths: [cfg.cliPath, cfg.ovPath],
|
|
274
|
+
env,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { statSync } from "node:fs";
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
11
|
|
|
12
|
+
import { buildOvHeaders } from "./ov-http.mjs";
|
|
13
|
+
|
|
12
14
|
const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
13
15
|
const DELETE_TIMEOUT_MS = 2000;
|
|
14
16
|
const MAX_CONCURRENT_REQUESTS = 16;
|
|
@@ -240,21 +242,27 @@ export function createOpenVikingMcpProxy({
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
function headersForRequest(includeSession = true) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
245
|
+
const built = buildOvHeaders(proxyConfig, {
|
|
246
|
+
actorPeerId: proxyConfig.peerId,
|
|
247
|
+
extraHeaders: {
|
|
248
|
+
"Accept": "application/json, text/event-stream",
|
|
249
|
+
// Always the proxy's current version (default, then server-negotiated) —
|
|
250
|
+
// never the client's un-negotiated ask, which strict upstreams reject
|
|
251
|
+
// with HTTP 400 before initialize negotiation can run.
|
|
252
|
+
"MCP-Protocol-Version": protocolVersion,
|
|
253
|
+
...(includeSession && sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
// Operator-supplied extras (OPENVIKING_EXTRA_HEADERS) merged with the
|
|
257
|
+
// proxy's own headers winning — the env-only escape hatch fills gaps for
|
|
258
|
+
// strict private-gateway upstreams (tenant/vault names, region hints)
|
|
259
|
+
// without letting a stray env var override authentication or session
|
|
260
|
+
// negotiation.
|
|
261
|
+
const opExtras = proxyConfig.extraHeaders;
|
|
262
|
+
if (opExtras && typeof opExtras === "object") {
|
|
263
|
+
return { ...opExtras, ...built };
|
|
264
|
+
}
|
|
265
|
+
return built;
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
function writeMessage(obj) {
|