mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
* fix(codex): read the MCP proxy's connection from the hooks' loadConfig
The proxy resolved url, api_key, account and user through the bare
credential chain while taking everything else from loadConfig(). Since
the loader became buildPluginConfig() it adds layers that chain never
sees: ovcli.conf's plugin.codex apiKey/accountId/userId, and ov.conf's
codex.apiKey when ovcli.conf names only the server. With either, hooks
authenticated and every MCP tool call went out without a key.
Codex also hands a stdio MCP server only the env vars .mcp.json lists,
and OPENVIKING_AUTH_MODE was not one of them, so an env-set auth mode
decided the identity headers for hooks but not for MCP calls.
* fix(dsh): forward the resolved auth mode and timeout to the MCP proxy
The proxy runs as a child whose env DSH scrubs, so the parent forwards
what it resolved. It forwarded the endpoint, key, account, user and
peer but not the auth mode or request timeout, so a Cordis patch that
set either configured the in-process runtime and not the MCP calls.
The proxy now also takes its credential source and watched paths from
the resolved config instead of a second credential-chain call, and a
shared test keeps every proxy that ships beside hooks off that chain.
* refactor(shared): one connection resolver for hooks and the MCP proxy
The credential chain lived in two layers. `resolveOpenVikingCredentials()`
could not read ovcli.conf's `plugin.<harness>` keys, the ov.conf harness
fallback or the root-key tail; `buildPluginConfig()` patched those in, and
any caller that used the lower layer alone resolved a different key and
identity than the hooks did.
`resolveConnection(harness, { env, files, hostInput, rootKeyFallback })`
now answers server, key, identity and auth mode in one place, reading only
host input, the environment and the two ~/.openviking files. The hook
loader and `buildProxyConnection()` both consume it, and the old
two-layer entry points (`resolveOpenVikingCredentials`, `resolveAuthMode`,
the credentials.mjs CLI) are gone so a half-resolved chain cannot be
written again.
Behaviour is unchanged for every hook harness (checked field by field
against the previous implementation over thousands of generated file/env
combinations). Two deliberate additions: the portable agent-plugins proxy
now honours ovcli.conf's `plugin` connection keys and ov.conf's harness
section like every other harness, and dsh hands the host's `authMode`
(or `auth_mode`) over as host input, ranking it with the host's endpoint,
key and identity.
* fix(shared): a forced env credential source reads only the environment
`OPENVIKING_CREDENTIAL_SOURCE=env` is documented as "env vars only", but
only the url honoured it: the key, account, user, ovcli.conf's actor peer
and the auth mode still fell through to ovcli.conf, its plugin keys,
ov.conf and the root key when the variable was unset. A process that
exported an empty key to mean "no key" was silently handed whatever the
files held.
Forced to `env`, the connection now reads no file and an unset variable
stays empty; the url defaults to http://127.0.0.1:1933. The `peerId`
setting keeps its own layers. The doctor labels that mode instead of
pointing at files the chain skipped.
* refactor(shared): one proxy-config mapper and one forwarded-env list
Each proxy entrypoint copied a dozen fields out of its loader by hand,
under two sets of names, and the copies had drifted. What the proxy
process must be handed was a second hand-kept list, in Codex's
`.mcp.json` and in its test.
`toMcpProxyConfig(cfg, options)` maps a resolved loader or proxy
connection to the proxy config once. `MCP_PROXY_ENV_VARS` names every
variable that changes what a proxy sends; Codex's `env_vars` is now
checked against it, which adds the missing `OPENVIKING_STATE_DIR`.
* refactor(plugins): every loader takes an env, every proxy exports readProxyConfig(env)
The six MCP proxy entrypoints now reduce to one line: resolve through the
harness's own loader (or `buildProxyConnection` for the hook-less package)
and hand the result to `toMcpProxyConfig`. Every one exports
`readProxyConfig(env)`, and the codex, claude-code, opencode and agent-hook
loaders accept an injected env, so a test can drive a hook and its proxy
from the same inputs without touching process.env.
Mapping through one function fixes what the hand copies had lost: the
Claude Code and DSH proxies never passed `mcpUrl`, so
`OPENVIKING_MCP_URL` moved the hooks and left the tools behind.
The source guard now requires the shared mapper and the exported reader
in every proxy, and `buildProxyConnection` reports its two config paths
instead of a watch list of its own.
* fix(dsh): forward the resolved connection to the MCP proxy
DSH starts its MCP subprocess with the parent's environment minus
credential-shaped names (`/KEY|PASSWORD|SECRET|TOKEN/i`), so the bundle
forwards what it resolved. It forwarded the values but not the mode, and
only the non-empty ones:
- A child that receives `OPENVIKING_URL` runs the chain unpinned. Where
the parent's chain was pinned to an ovcli.conf that names only a url,
the parent sent no key while the proxy fell through to ov.conf's
`server.root_api_key`, so the tools reached the server as root while
the hooks were anonymous.
- `OPENVIKING_ACCOUNT`, `OPENVIKING_USER` and `OPENVIKING_PEER_ID` survive
DSH's scrub, so a value the parent's chain ignored filled the gap in
the child and went out as an identity header.
- With no peer to forward, the proxy derived one from its own launch
directory and sent an actor peer the runtime did not.
`forwardConnectionEnv(connection)` now writes every credential variable,
the empty ones too, with the forced `env` source, so the child reads no
file and resolves exactly the parent's url, MCP url, key, identity, auth
mode and peer. The proxy takes its peer from that environment only.
`buildMcpConfig` moves to `mcp-env.mjs`, which carries no host
dependency, so shared tests can build the child environment without the
DSH bridge.
* test(shared): prove the proxy and the hooks resolve one connection
The existing guards checked shape — that a proxy called the shared
builder — never that it reached the server as the same caller its hooks
did, which is how two harnesses shipped proxies that disagreed with them.
`mcp-hook-parity.test.mjs` runs every harness that ships a proxy beside
hooks through a dozen configurations: ovcli.conf's own fields, its
`plugin.<harness>` and shared plugin keys, ov.conf-only installs, the
pinned fallbacks to a harness key and to the root key, credential and
auth-mode variables, a forced source over stale variables, an explicit
MCP URL, a host's own input, and a workspace file that tries to move the
connection. The hook loader sees the full environment; the proxy sees
only what its host lets through — Codex's `env_vars`, DSH's scrubbed
inheritance plus the forwarded connection, everyone else's full
environment — and the url, key, identity and identity-header switch they
put on the wire must match. Scenarios with a known answer pin it too, and
a coverage check fails when a new proxy or hook client has no row.
The two codex-only proxy tests the matrix now covers are removed.
* docs(plugins): one connection for hooks and MCP, and version bumps
The capability reference, plugin development guide, Agent Plugins and
Codex pages (en/zh), both doctor references and the plugin READMEs now
describe the chain `resolveConnection()` runs: host input first, the
pinned ovcli.conf branch and what still falls through it, the auth mode
reading `OPENVIKING_AUTH_MODE` and the `plugin` keys in every mode, a
forced `env` source reading no file, and the two ways a connection crosses
into an MCP process (Codex's forwarded-variable list, dsh's forwarded
connection). The parity test is registered with the credential tests.
Versions move past both this branch's base and main: claude-code 0.5.2,
codex 0.9.2, agent-hook 0.3.2, opencode 0.3.2, dsh 0.4.3, pi 0.3.2,
agent-plugins 0.1.2.
325 lines
15 KiB
JavaScript
325 lines
15 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { KNOB_BY_NAME } from "./lib/config-schema.mjs";
|
|
import { CONNECTION_ENV_VARS, CREDENTIAL_ENV_VARS, resolveConnection } from "./lib/credentials.mjs";
|
|
|
|
/**
|
|
* Run `fn` against an ovcli.conf / ov.conf pair in a throwaway directory. A
|
|
* file given as `null` is not written, so the chain sees it as absent.
|
|
*/
|
|
async function withFiles({ ovcli = null, ov = null }, fn) {
|
|
const dir = await mkdtemp(join(tmpdir(), "ov-creds-"));
|
|
const cliPath = join(dir, "ovcli.conf");
|
|
const ovPath = join(dir, "ov.conf");
|
|
if (ovcli) await writeFile(cliPath, JSON.stringify(ovcli));
|
|
if (ov) await writeFile(ovPath, JSON.stringify(ov));
|
|
const env = { OPENVIKING_CLI_CONFIG_FILE: cliPath, OPENVIKING_CONFIG_FILE: ovPath };
|
|
const write = async (files) => {
|
|
if (files.ovcli) await writeFile(cliPath, JSON.stringify(files.ovcli));
|
|
if (files.ov) await writeFile(ovPath, JSON.stringify(files.ov));
|
|
};
|
|
try {
|
|
return await fn({ env, cliPath, ovPath, write });
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const identity = (c) => ({ apiKey: c.apiKey, account: c.account, user: c.user });
|
|
|
|
test("credential env wins over ovcli config by default", async () => {
|
|
await withFiles({
|
|
ovcli: { url: "https://ov.example.com", api_key: "cli-key", account: "default", user: "zeus", actor_peer_id: "peer-a" },
|
|
}, async ({ env }) => {
|
|
const creds = resolveConnection("codex", {
|
|
env: {
|
|
...env,
|
|
OPENVIKING_URL: "https://stale.example.com",
|
|
OPENVIKING_MCP_URL: "https://stale.example.com/mcp",
|
|
OPENVIKING_API_KEY: "stale-key",
|
|
OPENVIKING_ACCOUNT: "stale-account",
|
|
OPENVIKING_USER: "stale-user",
|
|
OPENVIKING_PEER_ID: "stale-peer",
|
|
},
|
|
});
|
|
|
|
assert.equal(creds.credentialSource, "env");
|
|
assert.equal(creds.baseUrl, "https://stale.example.com");
|
|
assert.equal(creds.mcpUrl, "https://stale.example.com/mcp");
|
|
assert.deepEqual(identity(creds), { apiKey: "stale-key", account: "stale-account", user: "stale-user" });
|
|
assert.equal(creds.peerId, "stale-peer");
|
|
});
|
|
});
|
|
|
|
test("env source can be forced explicitly", async () => {
|
|
await withFiles({ ovcli: { url: "https://ov.example.com", api_key: "cli-key", user: "zeus" } }, async ({ env }) => {
|
|
const creds = resolveConnection("codex", {
|
|
env: {
|
|
...env,
|
|
OPENVIKING_CREDENTIAL_SOURCE: "env",
|
|
OPENVIKING_URL: "https://env.example.com",
|
|
OPENVIKING_MCP_URL: "https://env.example.com/custom-mcp",
|
|
OPENVIKING_API_KEY: "env-key",
|
|
OPENVIKING_ACCOUNT: "env-account",
|
|
OPENVIKING_USER: "env-user",
|
|
OPENVIKING_PEER_ID: "env-peer",
|
|
},
|
|
});
|
|
|
|
assert.equal(creds.credentialSource, "env");
|
|
assert.equal(creds.baseUrl, "https://env.example.com");
|
|
assert.equal(creds.mcpUrl, "https://env.example.com/custom-mcp");
|
|
assert.deepEqual(identity(creds), { apiKey: "env-key", account: "env-account", user: "env-user" });
|
|
assert.equal(creds.peerId, "env-peer");
|
|
});
|
|
});
|
|
|
|
test("a forced env source reads no file, so an empty variable stays empty", async () => {
|
|
await withFiles({
|
|
ovcli: { url: "https://cli.example.com", api_key: "cli-key", account: "acct-cli", actor_peer_id: "cli-peer", plugin: { userId: "usr-plugin", authMode: "trusted" } },
|
|
ov: { server: { url: "https://ov.example.com", root_api_key: "root-key", auth_mode: "trusted" }, codex: { apiKey: "sk-codex" } },
|
|
}, async ({ env }) => {
|
|
const bare = resolveConnection("codex", { env: { ...env, OPENVIKING_CREDENTIAL_SOURCE: "env" }, rootKeyFallback: true });
|
|
assert.equal(bare.credentialSource, "env");
|
|
assert.equal(bare.baseUrl, "http://127.0.0.1:1933");
|
|
assert.deepEqual(identity(bare), { apiKey: "", account: "", user: "" });
|
|
assert.equal(bare.apiKeySource, "none");
|
|
assert.equal(bare.peerId, "");
|
|
assert.equal(bare.authMode, "api_key");
|
|
|
|
const named = resolveConnection("codex", {
|
|
env: { ...env, OPENVIKING_CREDENTIALS_SOURCE: "environment", OPENVIKING_URL: "https://env.example.com", OPENVIKING_USER: "usr-env" },
|
|
});
|
|
assert.equal(named.mcpUrl, "https://env.example.com/mcp");
|
|
assert.deepEqual(identity(named), { apiKey: "", account: "", user: "usr-env" });
|
|
assert.equal(named.authMode, "trusted");
|
|
});
|
|
});
|
|
|
|
test("ovcli source can be forced explicitly without inheriting env key", async () => {
|
|
await withFiles({ ovcli: { url: "http://127.0.0.1:1933" } }, async ({ env }) => {
|
|
const creds = resolveConnection("codex", {
|
|
env: { ...env, OPENVIKING_CREDENTIAL_SOURCE: "ovcli", OPENVIKING_API_KEY: "stale-key" },
|
|
});
|
|
|
|
assert.equal(creds.credentialSource, "ovcli");
|
|
assert.equal(creds.baseUrl, "http://127.0.0.1:1933");
|
|
assert.equal(creds.apiKey, "");
|
|
assert.equal(creds.hasApiKey, false);
|
|
});
|
|
});
|
|
|
|
test("only a variable that names the connection takes the chain off ovcli.conf", async () => {
|
|
await withFiles({ ovcli: { url: "https://ov.example.com", api_key: "cli-key" } }, async ({ env }) => {
|
|
const tuned = resolveConnection("codex", {
|
|
env: { ...env, OPENVIKING_AUTH_MODE: "api_key", OPENVIKING_TIMEOUT_MS: "5000" },
|
|
});
|
|
assert.equal(tuned.credentialSource, "ovcli");
|
|
assert.equal(tuned.apiKey, "cli-key");
|
|
|
|
for (const name of CREDENTIAL_ENV_VARS) {
|
|
const value = name.endsWith("URL") ? "https://env.example.com" : "from-env";
|
|
assert.equal(
|
|
resolveConnection("codex", { env: { ...env, [name]: value } }).credentialSource,
|
|
"env",
|
|
`${name} names part of the connection`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("the key's source names the layer and the file behind it", async () => {
|
|
await withFiles({
|
|
ovcli: { url: "http://127.0.0.1:1933", api_key: "cli-key" },
|
|
ov: { server: { root_api_key: "root-key" } },
|
|
}, async ({ env, cliPath, ovPath, write }) => {
|
|
const source = (c) => [c.apiKeySource, c.credentialPath];
|
|
assert.deepEqual(source(resolveConnection("codex", { env })), ["ovcli", cliPath]);
|
|
|
|
// env beats both files, so no file is named; a host beats env.
|
|
assert.deepEqual(source(resolveConnection("codex", { env: { ...env, OPENVIKING_API_KEY: "env-key" } })), ["env", ""]);
|
|
assert.deepEqual(source(resolveConnection("codex", { env, hostInput: { apiKey: "host-key" } })), ["host", ""]);
|
|
|
|
// A tuning-only ovcli.conf carries no credentials, so the chain lands on ov.conf.
|
|
await write({ ovcli: { plugin: { recallCompress: "off" } } });
|
|
const creds = resolveConnection("codex", { env });
|
|
assert.equal(creds.apiKey, "root-key");
|
|
assert.deepEqual(source(creds), ["ov", ovPath]);
|
|
|
|
await write({ ovcli: { url: "http://127.0.0.1:1933" } });
|
|
assert.deepEqual(source(resolveConnection("codex", { env })), ["none", ""]);
|
|
});
|
|
});
|
|
|
|
test("each harness reads its own ov.conf section, not codex's", async () => {
|
|
await withFiles({
|
|
ov: {
|
|
server: { root_api_key: "root-key" },
|
|
codex: { apiKey: "sk-codex", accountId: "acct-codex", userId: "user-codex", peerId: "peer-codex" },
|
|
opencode: { apiKey: "sk-opencode", accountId: "acct-opencode", userId: "user-opencode", peerId: "peer-opencode" },
|
|
trae_cn: { apiKey: "sk-trae-cn" },
|
|
},
|
|
}, async ({ env }) => {
|
|
const codex = resolveConnection("codex", { env });
|
|
assert.deepEqual(identity(codex), { apiKey: "sk-codex", account: "acct-codex", user: "user-codex" });
|
|
assert.equal(codex.peerId, "peer-codex");
|
|
|
|
const opencode = resolveConnection("opencode", { env });
|
|
assert.deepEqual(identity(opencode), { apiKey: "sk-opencode", account: "acct-opencode", user: "user-opencode" });
|
|
assert.equal(opencode.peerId, "peer-opencode");
|
|
|
|
// Either spelling of a harness name reaches the snake_case section.
|
|
assert.equal(resolveConnection("trae-cn", { env }).apiKey, "sk-trae-cn");
|
|
|
|
// A harness with no section of its own inherits nothing from codex's; the
|
|
// chain carries on to server.root_api_key as it always did.
|
|
const cursor = resolveConnection("cursor", { env });
|
|
assert.deepEqual(identity(cursor), { apiKey: "root-key", account: "", user: "" });
|
|
assert.equal(cursor.peerId, "");
|
|
});
|
|
});
|
|
|
|
test("ovcli.conf's plugin keys rank under its own fields and over ov.conf", async () => {
|
|
await withFiles({
|
|
ovcli: {
|
|
plugin: {
|
|
apiKey: "sk-plugin",
|
|
accountId: "acct-plugin",
|
|
userId: "usr-plugin",
|
|
claude_code: { apiKey: "sk-snake", accountId: "acct-snake" },
|
|
"claude-code": { apiKey: "sk-hyphen" },
|
|
},
|
|
},
|
|
ov: { server: { root_api_key: "root-key" }, claude_code: { apiKey: "sk-ov", accountId: "acct-ov", userId: "usr-ov" } },
|
|
}, async ({ env, write }) => {
|
|
// Shared keys, then plugin.claude_code over them, then the hyphenated
|
|
// spelling over that — the merge every knob in that section follows.
|
|
assert.deepEqual(identity(resolveConnection("claude-code", { env })), {
|
|
apiKey: "sk-hyphen",
|
|
account: "acct-snake",
|
|
user: "usr-plugin",
|
|
});
|
|
|
|
// A number is a value, as the knob schema coerces it.
|
|
await write({ ovcli: { plugin: { accountId: 12345 } } });
|
|
assert.equal(resolveConnection("codex", { env }).account, "12345");
|
|
|
|
// An empty scoped key blanks the shared one and the chain carries on.
|
|
await write({ ovcli: { plugin: { apiKey: "sk-plugin", claude_code: { apiKey: "" } } } });
|
|
assert.equal(resolveConnection("claude-code", { env }).apiKey, "sk-ov");
|
|
|
|
await write({ ovcli: { url: "http://127.0.0.1:1933", api_key: "sk-cli", plugin: { apiKey: "sk-plugin", userId: "usr-plugin" } } });
|
|
const pinned = resolveConnection("claude-code", { env });
|
|
assert.equal(pinned.credentialSource, "ovcli");
|
|
assert.equal(pinned.apiKey, "sk-cli");
|
|
assert.equal(pinned.user, "usr-plugin");
|
|
});
|
|
});
|
|
|
|
test("pinned, the key falls back to ov.conf's harness section but the identity does not", async () => {
|
|
await withFiles({
|
|
ovcli: { url: "http://127.0.0.1:1933", actor_peer_id: "cli-peer" },
|
|
ov: {
|
|
server: { root_api_key: "root-key" },
|
|
opencode: { apiKey: "sk-opencode", accountId: "acct-opencode", userId: "usr-opencode", peerId: "peer-opencode" },
|
|
},
|
|
}, async ({ env, ovPath, write }) => {
|
|
const pinned = resolveConnection("opencode", { env });
|
|
assert.equal(pinned.credentialSource, "ovcli");
|
|
assert.deepEqual(identity(pinned), { apiKey: "sk-opencode", account: "", user: "" });
|
|
assert.equal(pinned.peerId, "cli-peer");
|
|
assert.equal(pinned.credentialPath, ovPath);
|
|
|
|
// With ovcli.conf holding tuning only, the whole harness section applies
|
|
// and still sits ahead of server.root_api_key.
|
|
await write({ ovcli: { plugin: { recallCompress: "off" } } });
|
|
const layered = resolveConnection("opencode", { env });
|
|
assert.deepEqual(identity(layered), { apiKey: "sk-opencode", account: "acct-opencode", user: "usr-opencode" });
|
|
assert.equal(layered.peerId, "peer-opencode");
|
|
});
|
|
});
|
|
|
|
test("the root key ends every unpinned chain, and a pinned one only on request", async () => {
|
|
await withFiles({ ov: { server: { root_api_key: "root-key" } } }, async ({ env, write }) => {
|
|
const key = (rootKeyFallback) => resolveConnection("codex", { env, rootKeyFallback }).apiKey;
|
|
assert.equal(key(false), "root-key");
|
|
assert.equal(key(true), "root-key");
|
|
|
|
await write({ ovcli: { url: "http://127.0.0.1:1933" } });
|
|
assert.equal(key(false), "");
|
|
assert.equal(key(true), "root-key");
|
|
});
|
|
});
|
|
|
|
test("the auth mode walks host, env, plugin, harness section, server, then the identity", async () => {
|
|
const ovcli = {
|
|
api_key: "sk-cli",
|
|
account: "acct",
|
|
plugin: { auth_mode: "trusted", codex: { authMode: "api_key" } },
|
|
};
|
|
const ov = { server: { auth_mode: "trusted" }, codex: { authMode: "trusted" } };
|
|
await withFiles({ ovcli, ov }, async ({ env, write }) => {
|
|
const mode = (options = {}) => {
|
|
const c = resolveConnection("codex", { env, ...options });
|
|
assert.equal(c.sendIdentityHeaders, c.authMode === "trusted");
|
|
return c.authMode;
|
|
};
|
|
assert.equal(mode(), "api_key", "plugin.codex outranks the shared plugin key");
|
|
assert.equal(mode({ env: { ...env, OPENVIKING_AUTH_MODE: "TRUSTED" } }), "trusted");
|
|
assert.equal(
|
|
mode({ env: { ...env, OPENVIKING_AUTH_MODE: "trusted" }, hostInput: { authMode: "api_key" } }),
|
|
"api_key",
|
|
"a host's own answer outranks the environment",
|
|
);
|
|
|
|
await write({ ovcli: { ...ovcli, plugin: { auth_mode: "api_key" } } });
|
|
assert.equal(mode(), "api_key", "the snake_case spelling counts");
|
|
|
|
await write({ ovcli: { ...ovcli, plugin: {} }, ov: { ...ov, codex: { auth_mode: "api_key" } } });
|
|
assert.equal(mode(), "api_key", "ov.conf's harness section ranks over server.auth_mode");
|
|
|
|
await write({ ov: { server: { auth_mode: "api_key" } } });
|
|
assert.equal(mode(), "api_key");
|
|
|
|
await write({ ov: { server: { auth_mode: "bogus" } } });
|
|
assert.equal(mode(), "trusted", "an identity means the deployment expects one");
|
|
|
|
await write({ ovcli: { api_key: "sk-cli" } });
|
|
assert.equal(mode(), "api_key");
|
|
assert.equal(mode({ hostInput: { user: "host-user" } }), "trusted", "a host's identity counts too");
|
|
});
|
|
});
|
|
|
|
test("a host's endpoint outranks every URL, including an explicit MCP URL", async () => {
|
|
await withFiles({ ovcli: { url: "https://cli.example.com/" } }, async ({ env }) => {
|
|
assert.equal(resolveConnection("dsh", { env }).mcpUrl, "https://cli.example.com/mcp");
|
|
|
|
const withMcpUrl = { ...env, OPENVIKING_MCP_URL: "https://env.example.com/custom" };
|
|
assert.equal(resolveConnection("dsh", { env: withMcpUrl }).mcpUrl, "https://env.example.com/custom");
|
|
|
|
const hosted = resolveConnection("dsh", { env: withMcpUrl, hostInput: { baseUrl: "https://host.example.com/" } });
|
|
assert.equal(hosted.baseUrl, "https://host.example.com");
|
|
assert.equal(hosted.mcpUrl, "https://host.example.com/mcp");
|
|
});
|
|
});
|
|
|
|
// credentials.mjs reads these knobs out of the plugin section itself, so the
|
|
// portable bundle does not have to ship the schema; every spelling the schema
|
|
// accepts has to land.
|
|
test("every spelling of a connection knob in the schema reaches the chain", async () => {
|
|
const values = { apiKey: ["apiKey", "sk-plugin"], accountId: ["account", "acct-plugin"], userId: ["user", "usr-plugin"], authMode: ["authMode", "trusted"] };
|
|
for (const [name, [field, value]] of Object.entries(values)) {
|
|
const knob = KNOB_BY_NAME.get(name);
|
|
assert.equal(knob.capability, "connection");
|
|
if (knob.env) assert.ok(CONNECTION_ENV_VARS.includes(knob.env), `${knob.env} is a connection variable`);
|
|
for (const spelling of [name, ...(knob.aliases || [])]) {
|
|
await withFiles({ ovcli: { plugin: { codex: { [spelling]: value } } }, ov: { server: { auth_mode: "api_key" } } }, async ({ env }) => {
|
|
assert.equal(resolveConnection("codex", { env })[field], value, `plugin.codex.${spelling}`);
|
|
});
|
|
}
|
|
}
|
|
});
|