Skip to content

Commit ccfff7c

Browse files
committed
feat: validate feedback configuration
1 parent 66d23ae commit ccfff7c

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

src/config/load-config.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
AgentTarget,
77
AgentsMode,
88
AgentsSection,
9+
ContextFeedbackConfig,
910
ContextRegistryConfig,
1011
EvidencePolicyMode,
1112
OpenCodePlusplusConfig,
@@ -41,6 +42,12 @@ export function loadConfig(repoRoot: string, overrides: Partial<OpenCodePlusplus
4142
fileConfig.contextRegistry?.sources ??
4243
DEFAULT_CONFIG.contextRegistry.sources
4344
},
45+
feedback: {
46+
...DEFAULT_CONFIG.feedback,
47+
...fileConfig.feedback,
48+
...localConfig.feedback,
49+
...overrides.feedback
50+
},
4451
llm: {
4552
...DEFAULT_CONFIG.llm,
4653
...fileConfig.llm,
@@ -104,6 +111,7 @@ function normalizeConfig(input: Record<string, unknown> | null | undefined): Par
104111
typeof input.contextRegistry === "object" && input.contextRegistry
105112
? normalizeContextRegistryConfig(input.contextRegistry as Record<string, unknown>)
106113
: undefined,
114+
feedback: typeof input.feedback === "object" && input.feedback ? normalizeFeedbackConfig(input.feedback as Record<string, unknown>) : undefined,
107115
tokenBudget: typeof input.tokenBudget === "number" ? input.tokenBudget : undefined,
108116
include: toStringArray(input.include),
109117
exclude: toStringArray(input.exclude),
@@ -123,6 +131,16 @@ function normalizeContextRegistryConfig(input: Record<string, unknown>): Partial
123131
});
124132
}
125133

134+
function normalizeFeedbackConfig(input: Record<string, unknown>): Partial<ContextFeedbackConfig> {
135+
return stripUndefined({
136+
enabled: typeof input.enabled === "boolean" ? input.enabled : undefined,
137+
telemetry: typeof input.telemetry === "boolean" ? input.telemetry : undefined,
138+
network: typeof input.network === "boolean" ? input.network : undefined,
139+
useLocalQualitySignals: typeof input.useLocalQualitySignals === "boolean" ? input.useLocalQualitySignals : undefined,
140+
endpoint: typeof input.endpoint === "string" ? input.endpoint : undefined
141+
});
142+
}
143+
126144
function normalizeContextSourceConfig(input: unknown): ContextSourceConfig {
127145
const source = typeof input === "object" && input ? (input as Record<string, unknown>) : {};
128146
return {
@@ -145,6 +163,7 @@ export function validateConfig(config: OpenCodePlusplusConfig): void {
145163
if (!["advisory", "balanced", "strict"].includes(config.evidencePolicy)) {
146164
throw new Error(`Invalid evidencePolicy "${config.evidencePolicy}". Expected one of: advisory, balanced, strict.`);
147165
}
166+
validateFeedbackConfig(config.feedback);
148167
if (!Number.isFinite(config.tokenBudget) || config.tokenBudget <= 0) {
149168
throw new Error("tokenBudget must be a positive number.");
150169
}
@@ -211,6 +230,7 @@ function validateRawConfig(input: Record<string, unknown> | null | undefined, so
211230
if (new Set(names).size !== names.length) throw new Error("contextRegistry.sources names must be unique.");
212231
}
213232
}
233+
if (input.feedback !== undefined) validateRawFeedbackConfig(input.feedback);
214234
if (input.tokenBudget !== undefined && (typeof input.tokenBudget !== "number" || input.tokenBudget <= 0)) {
215235
throw new Error("tokenBudget must be a positive number.");
216236
}
@@ -273,6 +293,42 @@ function validateRawConfig(input: Record<string, unknown> | null | undefined, so
273293
void source;
274294
}
275295

296+
function validateFeedbackConfig(config: ContextFeedbackConfig): void {
297+
if (
298+
typeof config.enabled !== "boolean" ||
299+
typeof config.telemetry !== "boolean" ||
300+
typeof config.network !== "boolean" ||
301+
typeof config.useLocalQualitySignals !== "boolean"
302+
) {
303+
throw new Error("feedback switches must be boolean.");
304+
}
305+
if (config.endpoint !== undefined) validateFeedbackEndpoint(config.endpoint);
306+
if (config.network && !config.endpoint) throw new Error("feedback.endpoint is required when feedback.network is true.");
307+
}
308+
309+
function validateRawFeedbackConfig(input: unknown): void {
310+
const feedback = objectValue(input, "feedback");
311+
for (const field of ["enabled", "telemetry", "network", "useLocalQualitySignals"]) {
312+
if (feedback[field] !== undefined && typeof feedback[field] !== "boolean") throw new Error("feedback." + field + " must be boolean.");
313+
}
314+
if (feedback.endpoint !== undefined) {
315+
if (typeof feedback.endpoint !== "string" || !feedback.endpoint.trim()) throw new Error("feedback.endpoint must be a non-empty URL.");
316+
validateFeedbackEndpoint(feedback.endpoint);
317+
}
318+
const allowed = new Set(["enabled", "telemetry", "network", "useLocalQualitySignals", "endpoint"]);
319+
for (const key of Object.keys(feedback)) if (!allowed.has(key)) throw new Error("Unknown feedback option: " + key + ".");
320+
if (feedback.network === true && feedback.endpoint === undefined) throw new Error("feedback.endpoint is required when feedback.network is true.");
321+
}
322+
323+
function validateFeedbackEndpoint(value: string): void {
324+
try {
325+
const url = new URL(value);
326+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("must use an HTTP(S) URL without credentials");
327+
} catch (error) {
328+
throw new Error("feedback.endpoint " + (error instanceof Error ? error.message : "must be a valid URL") + ".");
329+
}
330+
}
331+
276332
function validateContextSourceConfig(input: unknown, index: number): void {
277333
const source = objectValue(input, `contextRegistry.sources[${index}]`);
278334
for (const field of ["name", "kind", "location", "trustLevel"]) {

test/config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,37 @@ test("legacy config keeps the context registry disabled and offline", () => {
3939
}
4040
});
4141

42+
test("feedback configuration supports offline defaults and explicit network validation", () => {
43+
const root = mkdtempSync(path.join(tmpdir(), "opencode-plusplus-feedback-config-"));
44+
try {
45+
writeFileSync(path.join(root, "opencode-plusplus.config.yml"), "target: opencode\n", "utf8");
46+
assert.deepEqual(loadConfig(root).feedback, {
47+
enabled: true,
48+
telemetry: false,
49+
network: false,
50+
useLocalQualitySignals: false
51+
});
52+
writeFileSync(
53+
path.join(root, "opencode-plusplus.config.yml"),
54+
"feedback:\n enabled: false\n telemetry: true\n network: true\n endpoint: https://feedback.example.test/v1\n",
55+
"utf8"
56+
);
57+
assert.deepEqual(loadConfig(root).feedback, {
58+
enabled: false,
59+
telemetry: true,
60+
network: true,
61+
useLocalQualitySignals: false,
62+
endpoint: "https://feedback.example.test/v1"
63+
});
64+
writeFileSync(path.join(root, "opencode-plusplus.config.yml"), "feedback:\n network: true\n", "utf8");
65+
assert.throws(() => loadConfig(root), /endpoint is required/);
66+
writeFileSync(path.join(root, "opencode-plusplus.config.yml"), "feedback:\n endpoint: https://user:password@example.test\n", "utf8");
67+
assert.throws(() => loadConfig(root), /without credentials/);
68+
} finally {
69+
rmSync(root, { recursive: true, force: true });
70+
}
71+
});
72+
4273
test("context registry source configuration is normalized and validated", () => {
4374
const root = mkdtempSync(path.join(tmpdir(), "opencode-plusplus-config-"));
4475
try {

0 commit comments

Comments
 (0)