@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
package/lib/shared/uri-guard.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
2
6
|
const DEFAULT_URI_KEYS = [
|
|
3
7
|
"filePath",
|
|
4
8
|
"file_path",
|
|
@@ -7,7 +11,6 @@ const DEFAULT_URI_KEYS = [
|
|
|
7
11
|
"uri",
|
|
8
12
|
"target_uri",
|
|
9
13
|
"targetUri",
|
|
10
|
-
"pattern",
|
|
11
14
|
];
|
|
12
15
|
|
|
13
16
|
export function normalizeToolName(value) {
|
|
@@ -76,3 +79,172 @@ export function buildGuardMessage(uri, hint = {}) {
|
|
|
76
79
|
if (example) lines.push(`Example: ${example}`);
|
|
77
80
|
return lines.join("\n");
|
|
78
81
|
}
|
|
82
|
+
|
|
83
|
+
export function buildGuardNotice(uri, hint = {}) {
|
|
84
|
+
const lines = [
|
|
85
|
+
`[OpenViking memory plugin] URI guard: this shell command contains the viking:// URI ${uri}.`,
|
|
86
|
+
"viking:// URIs are OpenViking virtual paths, not local files, so cat, ls, grep and other file commands cannot open them.",
|
|
87
|
+
`If you meant to read or search OpenViking content, use ${hint.tool || "the OpenViking MCP tools"} instead.`,
|
|
88
|
+
];
|
|
89
|
+
if (hint.example) lines.push(`Example: ${hint.example}`);
|
|
90
|
+
lines.push("If the URI is intentional data (an ov CLI argument, an HTTP payload, a search pattern), ignore this notice.");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The hints a host gets when it names no table of its own. */
|
|
95
|
+
export const DEFAULT_TOOL_HINTS = {
|
|
96
|
+
read: {
|
|
97
|
+
tool: "OpenViking MCP read",
|
|
98
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
99
|
+
},
|
|
100
|
+
glob: {
|
|
101
|
+
tool: "OpenViking MCP glob or list",
|
|
102
|
+
example: (uri, input = {}) => (
|
|
103
|
+
`glob(pattern="${String(input.pattern ?? "**/*").replaceAll('"', '\\"')}", uri="${uri}")`
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
grep: {
|
|
107
|
+
tool: "OpenViking MCP grep or search",
|
|
108
|
+
example: (uri, input = {}) => (
|
|
109
|
+
`grep(uri="${uri}", pattern="${String(input.pattern ?? "").replaceAll('"', '\\"')}")`
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
edit: {
|
|
113
|
+
tool: "OpenViking MCP edit",
|
|
114
|
+
example: (uri) => `edit(uri="${uri}", old_string="...", new_string="...")`,
|
|
115
|
+
},
|
|
116
|
+
write: {
|
|
117
|
+
tool: "OpenViking MCP write",
|
|
118
|
+
example: (uri) => `write(uri="${uri}", content="...")`,
|
|
119
|
+
},
|
|
120
|
+
bash: {
|
|
121
|
+
tool: "OpenViking MCP read or search",
|
|
122
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
123
|
+
},
|
|
124
|
+
runcommand: {
|
|
125
|
+
tool: "OpenViking MCP read or search",
|
|
126
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
127
|
+
},
|
|
128
|
+
shell: {
|
|
129
|
+
tool: "OpenViking MCP read or search",
|
|
130
|
+
example: (uri) => `read(uris="${uri}")`,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Tools whose argument is a command line rather than a location. A viking:// URI
|
|
135
|
+
// in one is data (an `ov` argument, an HTTP payload, a grep pattern) as often as
|
|
136
|
+
// a path the model hoped to open, so the command runs and the model gets a notice.
|
|
137
|
+
const SHELL_TOOL_NAMES = new Set(["bash", "shell", "runcommand"]);
|
|
138
|
+
|
|
139
|
+
// `pattern` is where glob looks but what grep looks for: a grep for the text
|
|
140
|
+
// "viking://" in a local tree is not a path. The generic sweep still reaches
|
|
141
|
+
// glob's pattern.
|
|
142
|
+
const TEXT_ARGS_BY_TOOL = { grep: ["pattern"] };
|
|
143
|
+
|
|
144
|
+
function resolveGuardedUri(toolName, input, { hints = DEFAULT_TOOL_HINTS } = {}) {
|
|
145
|
+
const name = normalizeToolName(toolName);
|
|
146
|
+
const hint = hints[name];
|
|
147
|
+
if (!hint) return null;
|
|
148
|
+
const textArgs = TEXT_ARGS_BY_TOOL[name];
|
|
149
|
+
const uri = findVikingUri(input, DEFAULT_URI_KEYS, textArgs ? [...DEFAULT_CONTENT_KEYS, ...textArgs] : DEFAULT_CONTENT_KEYS);
|
|
150
|
+
if (!uri) return null;
|
|
151
|
+
return {
|
|
152
|
+
uri,
|
|
153
|
+
shell: SHELL_TOOL_NAMES.has(name),
|
|
154
|
+
hint: {
|
|
155
|
+
tool: hint.tool,
|
|
156
|
+
example: typeof hint.example === "function" ? hint.example(uri, input) : hint.example,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The deny decision for one tool call, or null when the call may proceed.
|
|
163
|
+
*
|
|
164
|
+
* A file tool whose path is a viking:// URI cannot succeed, and a write would
|
|
165
|
+
* leave a junk local file, so it is denied. A shell tool is never denied here;
|
|
166
|
+
* `evaluateUriNotice` covers it. `hints` carries the host's replacement tool
|
|
167
|
+
* names and example calls, and a tool without a hint is not guarded.
|
|
168
|
+
*/
|
|
169
|
+
export function evaluateUriGuard(toolName, input = {}, opts = {}) {
|
|
170
|
+
const match = resolveGuardedUri(toolName, input, opts);
|
|
171
|
+
if (!match || match.shell) return null;
|
|
172
|
+
return { uri: match.uri, reason: buildGuardMessage(match.uri, match.hint) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The notice for a shell command that carries a viking:// URI, or null. The command still runs. */
|
|
176
|
+
export function evaluateUriNotice(toolName, input = {}, opts = {}) {
|
|
177
|
+
const match = resolveGuardedUri(toolName, input, opts);
|
|
178
|
+
if (!match?.shell) return null;
|
|
179
|
+
return { uri: match.uri, reason: buildGuardNotice(match.uri, match.hint) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The PreToolUse deny envelope claude-code, trae and zcode all read. */
|
|
183
|
+
export function denyHookSpecificOutput(reason) {
|
|
184
|
+
return {
|
|
185
|
+
hookSpecificOutput: {
|
|
186
|
+
hookEventName: "PreToolUse",
|
|
187
|
+
permissionDecision: "deny",
|
|
188
|
+
permissionDecisionReason: reason,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The PreToolUse notice envelope: no permissionDecision, so the host's own permission flow still runs. */
|
|
194
|
+
export function noticeHookSpecificOutput(reason) {
|
|
195
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reason } };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A hook event's tool name and input, under any of the spellings the hosts use. */
|
|
199
|
+
export function readToolEvent(event = {}) {
|
|
200
|
+
return {
|
|
201
|
+
toolName: event.tool_name ?? event.toolName ?? event.name ?? event.tool,
|
|
202
|
+
toolInput: event.tool_input ?? event.toolInput ?? event.input ?? {},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The whole PreToolUse guard: a deny envelope, a notice envelope, or {}. */
|
|
207
|
+
export function preToolUseOutput(event = {}, opts = {}) {
|
|
208
|
+
const { toolName, toolInput } = readToolEvent(event);
|
|
209
|
+
const denied = evaluateUriGuard(toolName, toolInput, opts);
|
|
210
|
+
if (denied) return denyHookSpecificOutput(denied.reason);
|
|
211
|
+
const notice = evaluateUriNotice(toolName, toolInput, opts);
|
|
212
|
+
return notice ? noticeHookSpecificOutput(notice.reason) : {};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Cursor's beforeReadFile deny envelope. */
|
|
216
|
+
export function denyCursorPermission(reason) {
|
|
217
|
+
return { permission: "deny", user_message: reason };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readHookInput() {
|
|
221
|
+
try {
|
|
222
|
+
const raw = readFileSync(0, "utf8").trim();
|
|
223
|
+
return raw ? JSON.parse(raw) : {};
|
|
224
|
+
} catch {
|
|
225
|
+
return {};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run a guard as a hook process: read the event off stdin, print the envelope.
|
|
231
|
+
*
|
|
232
|
+
* A guard is also imported by its harness tests, so the body only runs when the
|
|
233
|
+
* module is the process entrypoint. An empty envelope prints nothing — every
|
|
234
|
+
* host treats unrecognized or empty output as "no opinion", and one of them
|
|
235
|
+
* rejects any key it does not know.
|
|
236
|
+
*/
|
|
237
|
+
function isEntrypoint(moduleUrl) {
|
|
238
|
+
if (!process.argv[1]) return false;
|
|
239
|
+
try {
|
|
240
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(moduleUrl));
|
|
241
|
+
} catch {
|
|
242
|
+
return resolve(process.argv[1]) === fileURLToPath(moduleUrl);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function runUriGuardHook(moduleUrl, evaluate) {
|
|
247
|
+
if (!isEntrypoint(moduleUrl)) return;
|
|
248
|
+
const output = evaluate(readHookInput());
|
|
249
|
+
if (Object.keys(output).length > 0) process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
250
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Layered workspace configuration.
|
|
4
|
+
*
|
|
5
|
+
* A workspace may carry two files under `<root>/.openviking/`: `config.json`,
|
|
6
|
+
* which the team commits, and `config.local.json`, which stays private. A third
|
|
7
|
+
* layer lives per machine in the user registry. All three share this schema and
|
|
8
|
+
* this merge, so there is one set of rules to learn and one to test.
|
|
9
|
+
*
|
|
10
|
+
* These files are trusted without a prompt — a hook is non-interactive, and any
|
|
11
|
+
* approval gate degrades into "run one command per workspace first". What is
|
|
12
|
+
* refused instead is structural, and costs nobody anything:
|
|
13
|
+
*
|
|
14
|
+
* - connection and credential keys are stripped, loudly. "Which server does
|
|
15
|
+
* my data go to" must stay answerable without reading a repository.
|
|
16
|
+
* - `${VAR}` is never expanded here. Variable expansion is a property of the
|
|
17
|
+
* layer, not of the parser: a committed file that could expand `${HOME}`
|
|
18
|
+
* could exfiltrate the environment.
|
|
19
|
+
*
|
|
20
|
+
* Everything else a workspace file sets takes effect. What it turns off is
|
|
21
|
+
* announced rather than blocked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
25
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { WORKSPACE_ENUMS, WORKSPACE_KNOB_MAP, WORKSPACE_RANGES } from "./config-schema.mjs";
|
|
28
|
+
import { CONFIG_DIR_NAME, LOCAL_FILE, TEAM_FILE } from "./workspace-identity.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `JSON.parse` keeps `__proto__` as an own property, and assigning it walks
|
|
32
|
+
* into `Object.prototype`. Since `process.env` reads through the prototype
|
|
33
|
+
* chain and the environment outranks ovcli.conf, one such key in a committed
|
|
34
|
+
* file would set `OPENVIKING_URL` and `OPENVIKING_API_KEY` for the whole
|
|
35
|
+
* process — the exact thing every other rule here exists to prevent.
|
|
36
|
+
*/
|
|
37
|
+
const UNSAFE_KEYS = ["__proto__", "constructor", "prototype"];
|
|
38
|
+
|
|
39
|
+
// Deep enough for any real config; a file can nest far past the stack limit
|
|
40
|
+
// inside the 64 KiB cap, and an overflow here would take out sibling layers.
|
|
41
|
+
const MAX_DEPTH = 32;
|
|
42
|
+
|
|
43
|
+
export { CONFIG_DIR_NAME, TEAM_FILE, LOCAL_FILE };
|
|
44
|
+
export const CONFIG_VERSION = 1;
|
|
45
|
+
export const MAX_CONFIG_BYTES = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keys no workspace file may set, at any depth. Connection and credentials
|
|
49
|
+
* belong to ovcli.conf and the environment, full stop — this is the invariant
|
|
50
|
+
* git spells `protected configuration`.
|
|
51
|
+
*/
|
|
52
|
+
export const FORBIDDEN_KEYS = [
|
|
53
|
+
"url",
|
|
54
|
+
"base_url",
|
|
55
|
+
"mcp_url",
|
|
56
|
+
"api_key",
|
|
57
|
+
"bearer_token",
|
|
58
|
+
"root_api_key",
|
|
59
|
+
"gateway_token",
|
|
60
|
+
"oidc_token",
|
|
61
|
+
"ldap_username",
|
|
62
|
+
"ldap_password",
|
|
63
|
+
"account",
|
|
64
|
+
"account_id",
|
|
65
|
+
"user",
|
|
66
|
+
"user_id",
|
|
67
|
+
"auth_mode",
|
|
68
|
+
"extra_headers",
|
|
69
|
+
"credential_source",
|
|
70
|
+
"cli_config_file",
|
|
71
|
+
"config_file",
|
|
72
|
+
// The camelCase spellings the harness loaders use. The projection into
|
|
73
|
+
// harness knobs is an allowlist, so these could never take effect anyway —
|
|
74
|
+
// but someone who writes `apiKey` here deserves to be told it was ignored,
|
|
75
|
+
// not to have it vanish.
|
|
76
|
+
"baseUrl",
|
|
77
|
+
"mcpUrl",
|
|
78
|
+
"apiKey",
|
|
79
|
+
"bearerToken",
|
|
80
|
+
"rootApiKey",
|
|
81
|
+
"gatewayToken",
|
|
82
|
+
"accountId",
|
|
83
|
+
"userId",
|
|
84
|
+
"authMode",
|
|
85
|
+
"extraHeaders",
|
|
86
|
+
"credentialSource",
|
|
87
|
+
"credentialPath",
|
|
88
|
+
"configPath",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sections whose keys are the user's own vocabulary, not ours. The ban below is
|
|
93
|
+
* recursive so no section can smuggle a credential to a consumer that spreads
|
|
94
|
+
* it into a request — but a free-form map's `user` key is a label, and
|
|
95
|
+
* mangling it would be a bug, not a defence.
|
|
96
|
+
*/
|
|
97
|
+
export const FREE_FORM_SECTIONS = ["labels"];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The workspace schema is a projection of `config-schema.mjs`: the dotted key,
|
|
101
|
+
* its enum members and its range all come from the one knob declaration, so a
|
|
102
|
+
* knob cannot be spelled one way here and another way in the loaders.
|
|
103
|
+
*/
|
|
104
|
+
const ENUMS = WORKSPACE_ENUMS;
|
|
105
|
+
const RANGES = WORKSPACE_RANGES;
|
|
106
|
+
const KNOB_MAP = WORKSPACE_KNOB_MAP;
|
|
107
|
+
|
|
108
|
+
export const WORKSPACE_SCHEMA_KEYS = Object.keys(KNOB_MAP);
|
|
109
|
+
|
|
110
|
+
/** -1, 0 or 1 over dotted numeric versions; a non-numeric tail is ignored. */
|
|
111
|
+
function compareVersions(left, right) {
|
|
112
|
+
const parse = (value) => String(value || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
113
|
+
const a = parse(left);
|
|
114
|
+
const b = parse(right);
|
|
115
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
116
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
117
|
+
if (diff) return diff < 0 ? -1 : 1;
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `min_client_version` warns and never blocks. A committed file that could stop
|
|
124
|
+
* an older plugin from running would be a denial of service anyone with commit
|
|
125
|
+
* access could mount, so it says "this was written for a newer client" and the
|
|
126
|
+
* settings still apply.
|
|
127
|
+
*/
|
|
128
|
+
export function checkMinClientVersion(declared, clientVersion, warnings = []) {
|
|
129
|
+
const required = String(declared || "").trim();
|
|
130
|
+
const current = String(clientVersion || "").trim();
|
|
131
|
+
if (!required || !current) return true;
|
|
132
|
+
if (compareVersions(current, required) >= 0) return true;
|
|
133
|
+
warnings.push(
|
|
134
|
+
`this workspace asks for OpenViking plugin ${required} and this one is ${current}; `
|
|
135
|
+
+ "settings it introduced will be ignored rather than blocking the session",
|
|
136
|
+
);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isPlainObject(value) {
|
|
141
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Strip banned keys wherever they appear, collecting one warning per hit. They
|
|
146
|
+
* are removed rather than rejected: a file is not made unusable by carrying a
|
|
147
|
+
* key this layer refuses to honour.
|
|
148
|
+
*/
|
|
149
|
+
function stripForbidden(value, banned, warnings, path = "", depth = 0) {
|
|
150
|
+
if (depth > MAX_DEPTH) throw new RangeError(`nested more than ${MAX_DEPTH} levels at '${path}'`);
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
return value.map((item, index) => stripForbidden(item, banned, warnings, `${path}[${index}]`, depth + 1));
|
|
153
|
+
}
|
|
154
|
+
if (!isPlainObject(value)) return value;
|
|
155
|
+
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [key, child] of Object.entries(value)) {
|
|
158
|
+
const here = path ? `${path}.${key}` : key;
|
|
159
|
+
if (UNSAFE_KEYS.includes(key)) {
|
|
160
|
+
warnings.push(`ignored '${here}': a config file may not reach the object prototype`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (banned.includes(key)) {
|
|
164
|
+
warnings.push(`ignored '${here}': connection and credential settings belong in ovcli.conf or the environment`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Matched on the key itself, not on depth: a registry entry keeps the same
|
|
168
|
+
// section under `settings.`, and it must not end up stricter than the
|
|
169
|
+
// committed file it outranks.
|
|
170
|
+
out[key] = FREE_FORM_SECTIONS.includes(key)
|
|
171
|
+
? stripUnsafeOnly(child, warnings, here, depth + 1)
|
|
172
|
+
: stripForbidden(child, banned, warnings, here, depth + 1);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A free-form section keeps its own vocabulary, minus the prototype keys. */
|
|
178
|
+
function stripUnsafeOnly(value, warnings, path, depth) {
|
|
179
|
+
return stripForbidden(value, [], warnings, path, depth);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read one layer file. Every failure is a warning and an empty layer — a hook
|
|
184
|
+
* must not die because a config file is odd.
|
|
185
|
+
*/
|
|
186
|
+
export function readWorkspaceFile(path, { root = "", layer = "" } = {}) {
|
|
187
|
+
const warnings = [];
|
|
188
|
+
const empty = { path, layer, exists: false, data: null, warnings };
|
|
189
|
+
|
|
190
|
+
let stat;
|
|
191
|
+
try {
|
|
192
|
+
stat = statSync(path);
|
|
193
|
+
} catch {
|
|
194
|
+
return empty;
|
|
195
|
+
}
|
|
196
|
+
empty.exists = true;
|
|
197
|
+
if (!stat.isFile()) {
|
|
198
|
+
warnings.push(`${path} is not a regular file`);
|
|
199
|
+
return empty;
|
|
200
|
+
}
|
|
201
|
+
if (stat.size > MAX_CONFIG_BYTES) {
|
|
202
|
+
warnings.push(`${path} is larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
203
|
+
return empty;
|
|
204
|
+
}
|
|
205
|
+
// A symlink out of the workspace would let a repository read a file the user
|
|
206
|
+
// never meant to expose to it.
|
|
207
|
+
if (root) {
|
|
208
|
+
try {
|
|
209
|
+
const rel = relative(realpathSync(root), realpathSync(path));
|
|
210
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
|
|
211
|
+
warnings.push(`${path} resolves outside the workspace`);
|
|
212
|
+
return empty;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
warnings.push(`${path} could not be resolved`);
|
|
216
|
+
return empty;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
// Deliberately bare: no `${VAR}` expansion reaches a workspace file.
|
|
223
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
224
|
+
} catch (err) {
|
|
225
|
+
warnings.push(`${path} is not valid JSON (${err?.message || err})`);
|
|
226
|
+
return empty;
|
|
227
|
+
}
|
|
228
|
+
if (!isPlainObject(parsed)) {
|
|
229
|
+
warnings.push(`${path} must contain a JSON object`);
|
|
230
|
+
return empty;
|
|
231
|
+
}
|
|
232
|
+
if (parsed.version !== CONFIG_VERSION) {
|
|
233
|
+
warnings.push(`${path} declares version ${JSON.stringify(parsed.version)}; this client understands ${CONFIG_VERSION}`);
|
|
234
|
+
return empty;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const { version, $schema, min_client_version: minClientVersion, ...rest } = parsed;
|
|
238
|
+
let data;
|
|
239
|
+
try {
|
|
240
|
+
data = stripForbidden(rest, FORBIDDEN_KEYS, warnings);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
warnings.push(`${path} is nested too deeply (${err?.message || err})`);
|
|
243
|
+
return empty;
|
|
244
|
+
}
|
|
245
|
+
if (minClientVersion) data.min_client_version = String(minClientVersion);
|
|
246
|
+
|
|
247
|
+
return { path, layer, exists: true, data, warnings };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function shadow(provenance, here, value, source) {
|
|
251
|
+
const previous = provenance[here];
|
|
252
|
+
const shadowed = previous ? [{ value: previous.value, source: previous.source }, ...previous.shadowed] : [];
|
|
253
|
+
provenance[here] = { value, source, shadowed };
|
|
254
|
+
return provenance[here];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function mergeInto(target, source, layer, provenance, path = "", depth = 0) {
|
|
258
|
+
if (depth > MAX_DEPTH) throw new RangeError(`nested more than ${MAX_DEPTH} levels at '${path}'`);
|
|
259
|
+
for (const [key, value] of Object.entries(source)) {
|
|
260
|
+
if (UNSAFE_KEYS.includes(key)) continue;
|
|
261
|
+
const here = path ? `${path}.${key}` : key;
|
|
262
|
+
|
|
263
|
+
if (isPlainObject(value)) {
|
|
264
|
+
// A section replacing a scalar is still a change of the effective value,
|
|
265
|
+
// so the scalar has to be recorded as shadowed rather than left standing
|
|
266
|
+
// in provenance as if it were still in force.
|
|
267
|
+
if (!isPlainObject(target[key])) {
|
|
268
|
+
if (target[key] !== undefined) shadow(provenance, here, "(section)", layer);
|
|
269
|
+
target[key] = {};
|
|
270
|
+
}
|
|
271
|
+
mergeInto(target[key], value, layer, provenance, here, depth + 1);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (Array.isArray(value)) {
|
|
276
|
+
// `"!reset"` drops everything the lower layers contributed, the way
|
|
277
|
+
// EditorConfig's `unset` and git's empty `safe.directory` do.
|
|
278
|
+
const reset = value[0] === "!reset";
|
|
279
|
+
const incoming = reset ? value.slice(1) : value;
|
|
280
|
+
const inheritable = !reset && Array.isArray(target[key]);
|
|
281
|
+
const merged = inheritable ? [...target[key]] : [];
|
|
282
|
+
for (const item of incoming) if (!merged.includes(item)) merged.push(item);
|
|
283
|
+
|
|
284
|
+
// Only a genuine union credits both layers. A list landing on a scalar,
|
|
285
|
+
// or on nothing, belongs to this layer alone.
|
|
286
|
+
const previous = provenance[here];
|
|
287
|
+
if (inheritable && previous) {
|
|
288
|
+
provenance[here] = {
|
|
289
|
+
value: merged,
|
|
290
|
+
source: [previous.source, layer].filter(Boolean).join(" + "),
|
|
291
|
+
shadowed: previous.shadowed,
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
shadow(provenance, here, merged, reset ? `${layer} (reset)` : layer);
|
|
295
|
+
}
|
|
296
|
+
target[key] = merged;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
shadow(provenance, here, value, layer);
|
|
301
|
+
target[key] = value;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Merge layers given lowest-precedence first, returning the effective value
|
|
307
|
+
* plus, for every key, where it came from and what it covered up — the same
|
|
308
|
+
* question `git config --show-origin --show-scope` answers.
|
|
309
|
+
*/
|
|
310
|
+
export function mergeConfigLayers(layers, warnings = []) {
|
|
311
|
+
const value = {};
|
|
312
|
+
const provenance = {};
|
|
313
|
+
for (const { layer, data } of layers) {
|
|
314
|
+
if (!isPlainObject(data)) continue;
|
|
315
|
+
try {
|
|
316
|
+
mergeInto(value, data, layer, provenance);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
// One pathological layer must not take the others down with it.
|
|
319
|
+
warnings.push(`skipped ${layer}: ${err?.message || err}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { value, provenance };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function get(object, path) {
|
|
326
|
+
return path.split(".").reduce((node, key) => (isPlainObject(node) ? node[key] : undefined), object);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function set(object, path, value) {
|
|
330
|
+
const keys = path.split(".");
|
|
331
|
+
const last = keys.pop();
|
|
332
|
+
let node = object;
|
|
333
|
+
for (const key of keys) {
|
|
334
|
+
if (!isPlainObject(node[key])) node[key] = {};
|
|
335
|
+
node = node[key];
|
|
336
|
+
}
|
|
337
|
+
node[last] = value;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Clamp numbers and reject unknown enum values, warning once per key.
|
|
342
|
+
*
|
|
343
|
+
* A repository can raise a cost knob, so the ceiling is enforced here rather
|
|
344
|
+
* than trusted; an out-of-range value is clamped instead of rejected so a typo
|
|
345
|
+
* degrades rather than disables.
|
|
346
|
+
*/
|
|
347
|
+
export function normalizeWorkspaceConfig(value, warnings = []) {
|
|
348
|
+
for (const [path, { min, max, integer }] of Object.entries(RANGES)) {
|
|
349
|
+
const raw = get(value, path);
|
|
350
|
+
if (raw === undefined) continue;
|
|
351
|
+
// `Number()` turns null, true and [] into finite numbers, which would
|
|
352
|
+
// silently pin a knob to a bound instead of reporting a bad value.
|
|
353
|
+
const number = typeof raw === "number" || typeof raw === "string" ? Number(raw) : NaN;
|
|
354
|
+
if (!Number.isFinite(number)) {
|
|
355
|
+
warnings.push(`ignored '${path}': ${JSON.stringify(raw)} is not a number`);
|
|
356
|
+
set(value, path, undefined);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const clamped = Math.min(max, Math.max(min, integer ? Math.floor(number) : number));
|
|
360
|
+
if (clamped !== number) warnings.push(`clamped '${path}' from ${number} to ${clamped} (allowed ${min}..${max})`);
|
|
361
|
+
set(value, path, clamped);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
for (const [path, allowed] of Object.entries(ENUMS)) {
|
|
365
|
+
if (!allowed) continue;
|
|
366
|
+
const raw = get(value, path);
|
|
367
|
+
if (raw === undefined) continue;
|
|
368
|
+
if (!allowed.includes(raw)) {
|
|
369
|
+
warnings.push(`ignored '${path}': ${JSON.stringify(raw)} is not one of ${allowed.join(", ")}`);
|
|
370
|
+
set(value, path, undefined);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Flatten the merged workspace config into the flat knobs a harness loader
|
|
378
|
+
* reads, so the new layers slot in exactly where the ovcli `plugin` section
|
|
379
|
+
* already does and everything downstream — including the env vars that still
|
|
380
|
+
* win — keeps working unchanged.
|
|
381
|
+
*/
|
|
382
|
+
export function projectWorkspaceSettings(value) {
|
|
383
|
+
const settings = {};
|
|
384
|
+
for (const [path, knob] of Object.entries(KNOB_MAP)) {
|
|
385
|
+
const raw = get(value, path);
|
|
386
|
+
if (raw !== undefined) settings[knob] = raw;
|
|
387
|
+
}
|
|
388
|
+
return settings;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function workspaceConfigPaths(root) {
|
|
392
|
+
if (!root) return [];
|
|
393
|
+
const dir = join(root, CONFIG_DIR_NAME);
|
|
394
|
+
return [
|
|
395
|
+
{ layer: `${TEAM_FILE} (workspace)`, path: join(dir, TEAM_FILE) },
|
|
396
|
+
{ layer: `${LOCAL_FILE} (workspace)`, path: join(dir, LOCAL_FILE) },
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The two workspace-file layers, lowest precedence first. Callers stack the
|
|
402
|
+
* registry above these and the ovcli.conf plugin section below them.
|
|
403
|
+
*/
|
|
404
|
+
export function loadWorkspaceLayers(root, { clientVersion = "" } = {}) {
|
|
405
|
+
const warnings = [];
|
|
406
|
+
const layers = [];
|
|
407
|
+
for (const { layer, path } of workspaceConfigPaths(root)) {
|
|
408
|
+
const file = readWorkspaceFile(path, { root, layer });
|
|
409
|
+
warnings.push(...file.warnings);
|
|
410
|
+
if (!file.data) continue;
|
|
411
|
+
const { min_client_version: declared, ...data } = file.data;
|
|
412
|
+
checkMinClientVersion(declared, clientVersion, warnings);
|
|
413
|
+
layers.push({ layer, data, path });
|
|
414
|
+
}
|
|
415
|
+
return { layers, warnings };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Settings a workspace file turned off. Announced at session start and in
|
|
420
|
+
* doctor, because trusting the file is a decision the user should be able to
|
|
421
|
+
* see rather than one made silently on their behalf.
|
|
422
|
+
*/
|
|
423
|
+
export function announcedOverrides(provenance) {
|
|
424
|
+
const announced = [];
|
|
425
|
+
for (const [key, entry] of Object.entries(provenance)) {
|
|
426
|
+
const fromWorkspace = String(entry.source || "").includes("(workspace)");
|
|
427
|
+
if (!fromWorkspace) continue;
|
|
428
|
+
if (entry.value === false || key === "peer.id" || key === "peer.source" || key.startsWith("bypass.")) {
|
|
429
|
+
announced.push({ key, value: entry.value, source: entry.source });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return announced;
|
|
433
|
+
}
|
|
@@ -40,6 +40,69 @@ export const DEFAULT_PEER_SOURCE = "git";
|
|
|
40
40
|
|
|
41
41
|
const VARIABLE_RE = /\{([a-z_]+)\}/g;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Layers `resolvePluginPeerId` must not read off the merged settings, because
|
|
45
|
+
* each has a rank of its own: the environment ranks above every file, and
|
|
46
|
+
* ov.conf's harness block ranks below all of them.
|
|
47
|
+
*/
|
|
48
|
+
const PEER_LAYERS_RANKED_SEPARATELY = new Set(["env", "ov.conf"]);
|
|
49
|
+
|
|
50
|
+
function trimmed(value) {
|
|
51
|
+
return typeof value === "string" ? value.trim() : "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The peer some layer named, in the one order every harness follows.
|
|
56
|
+
*
|
|
57
|
+
* Both halves of a loader's configuration carry a peer: `resolveSettings()`
|
|
58
|
+
* reads the workspace file, the machine registry and ovcli.conf's `plugin`
|
|
59
|
+
* section, while the credential chain ends in ovcli.conf's `actor_peer_id`. The
|
|
60
|
+
* more specific layer wins, so a `peerId` written for this harness outranks the
|
|
61
|
+
* account-wide actor peer — codex resolved it that way and opencode and pi
|
|
62
|
+
* resolved it the other way round, which meant one ovcli.conf produced two
|
|
63
|
+
* different peers depending on which host read it.
|
|
64
|
+
*
|
|
65
|
+
* ov.conf's harness block, where this tuning used to live, stays at the bottom.
|
|
66
|
+
* It gets its own rank rather than riding the credential chain, which drops it
|
|
67
|
+
* entirely once the credentials are pinned to ovcli.conf.
|
|
68
|
+
*
|
|
69
|
+
* `OPENVIKING_PEER_ID` still outranks every file, except when the credentials
|
|
70
|
+
* are pinned to ovcli.conf, where the environment is meant not to apply at all.
|
|
71
|
+
* A host that names a peer itself (dsh's cordis patch) outranks all of them.
|
|
72
|
+
*
|
|
73
|
+
* Returns "" when nobody named one, which is what makes
|
|
74
|
+
* `resolveEffectivePeerId()` derive one from `peer.source`.
|
|
75
|
+
*/
|
|
76
|
+
export function resolvePluginPeerId({
|
|
77
|
+
settings = {},
|
|
78
|
+
configured = null,
|
|
79
|
+
sources = {},
|
|
80
|
+
credentials = {},
|
|
81
|
+
hostInput = "",
|
|
82
|
+
env = process.env,
|
|
83
|
+
credentialSource = credentials?.credentialSource || "",
|
|
84
|
+
} = {}) {
|
|
85
|
+
const host = trimmed(hostInput);
|
|
86
|
+
if (host) return host;
|
|
87
|
+
|
|
88
|
+
if (credentialSource !== "ovcli") {
|
|
89
|
+
const fromEnv = trimmed(env?.OPENVIKING_PEER_ID);
|
|
90
|
+
if (fromEnv) return fromEnv;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (configured?.has?.("peerId") && !PEER_LAYERS_RANKED_SEPARATELY.has(sources?.peerId)) {
|
|
94
|
+
const named = trimmed(settings?.peerId);
|
|
95
|
+
if (named) return named;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fromCredentials = trimmed(credentials?.peerId);
|
|
99
|
+
if (fromCredentials) return fromCredentials;
|
|
100
|
+
|
|
101
|
+
if (sources?.peerId === "ov.conf") return trimmed(settings?.peerId);
|
|
102
|
+
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
|
|
43
106
|
export function deriveWorkspacePeerId(cwd) {
|
|
44
107
|
return legacySanitize(cwd);
|
|
45
108
|
}
|