Skip to content

Commit cb8f19d

Browse files
Harden provider and plugin edge cases
1 parent 0d0fe4e commit cb8f19d

4 files changed

Lines changed: 178 additions & 6 deletions

File tree

src/errors/index.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,14 @@ export function classifyCommandFailure(input: {
136136
}
137137

138138
export function classifyAIProviderError(error: unknown, context: Partial<SetuprErrorInput> = {}): SetuprError {
139-
const raw = error instanceof Error ? error.message : String(error || "");
139+
const raw = providerErrorText(error);
140140
const lower = raw.toLowerCase();
141141
let code: SetuprErrorCode = "AI_PROVIDER_REQUEST_FAILED";
142142
if (/timed? out|timeout|abort/.test(lower)) code = "AI_PROVIDER_TIMEOUT";
143143
else if (/401|unauthorized|invalid api key|authentication/.test(lower)) code = "AI_PROVIDER_AUTH_FAILED";
144144
else if (/403|forbidden|access denied/.test(lower)) code = "AI_PROVIDER_AUTH_FAILED";
145+
else if (/insufficient[_ -]?quota|quota|credit|insufficient balance|billing hard limit|out of credits/.test(lower)) code = "AI_PROVIDER_QUOTA_EXHAUSTED";
145146
else if (/429|rate limit|too many requests/.test(lower)) code = "AI_PROVIDER_RATE_LIMITED";
146-
else if (/quota|credit|insufficient balance|billing|exceeded/.test(lower)) code = "AI_PROVIDER_QUOTA_EXHAUSTED";
147147
else if (/500|502|503|504|unavailable|overloaded/.test(lower)) code = "AI_PROVIDER_UNAVAILABLE";
148148
else if (/json|parse|invalid response|protocol/.test(lower)) code = "AI_PROVIDER_PROTOCOL_ERROR";
149149

@@ -160,6 +160,37 @@ export function classifyAIProviderError(error: unknown, context: Partial<SetuprE
160160
});
161161
}
162162

163+
function providerErrorText(error: unknown): string {
164+
const parts: string[] = [];
165+
const seen = new Set<unknown>();
166+
167+
const collect = (value: unknown, depth: number) => {
168+
if (value === null || value === undefined || seen.has(value) || depth > 2) return;
169+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
170+
parts.push(String(value));
171+
return;
172+
}
173+
if (value instanceof Error) {
174+
parts.push(value.name);
175+
parts.push(value.message);
176+
}
177+
if (typeof value !== "object") return;
178+
seen.add(value);
179+
const record = value as Record<string, unknown>;
180+
for (const key of ["status", "statusCode", "code", "type", "name", "message", "statusText"]) {
181+
const hint = record[key];
182+
if (typeof hint === "string" || typeof hint === "number") parts.push(`${key}: ${hint}`);
183+
}
184+
for (const key of ["error", "response", "body", "data", "cause"]) {
185+
if (record[key] !== undefined) collect(record[key], depth + 1);
186+
}
187+
};
188+
189+
collect(error, 0);
190+
const raw = parts.filter(Boolean).join(" | ").trim();
191+
return raw || String(error || "");
192+
}
193+
163194
export function sanitizeSecret(value: string): string {
164195
let result = value;
165196
result = result.replace(

src/plugins/runtime.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { readFile } from "fs/promises";
22
import { existsSync } from "fs";
33
import { pathToFileURL } from "url";
4-
import { join, resolve } from "path";
4+
import { isAbsolute, join, relative, resolve } from "path";
55
import type { SetupStep } from "../ai/planner.js";
66
import type { ProjectContext } from "../ai/dsl.js";
77
import { collectContext } from "../context/collector.js";
88
import { loadConfig, type PluginEntry } from "../state/config.js";
99
import { scanProject, type ScanResult } from "../scanner/index.js";
10+
import { createSetuprError } from "../errors/index.js";
1011
import type {
1112
SetuprPlugin,
1213
SetuprPluginContext,
@@ -173,7 +174,17 @@ export async function tryRunPluginCommand(input: {
173174
for (const { plugin } of plugins) {
174175
const command = plugin.commands?.find((candidate) => candidate.name === input.command);
175176
if (!command) continue;
176-
await command.run(await pluginContext(input.cwd, scan, context, input.log), input.args);
177+
try {
178+
await command.run(await pluginContext(input.cwd, scan, context, input.log), input.args);
179+
} catch (err) {
180+
throw createSetuprError({
181+
code: "PLUGIN_LOAD_FAILED",
182+
command: input.command,
183+
cwd: input.cwd,
184+
details: [`Plugin: ${plugin.name}`, err instanceof Error ? err.message : String(err)],
185+
cause: err,
186+
});
187+
}
177188
return true;
178189
}
179190

@@ -231,18 +242,46 @@ async function loadPluginFromDir(name: string, dir: string): Promise<LoadedPlugi
231242
function resolveEntrypoint(dir: string, manifest: PluginManifest): string | null {
232243
const candidates = [
233244
typeof manifest.exports === "string" ? manifest.exports : undefined,
245+
...exportsObjectEntrypoints(manifest.exports),
234246
manifest.main,
235247
"dist/index.js",
236248
"index.js",
237249
].filter((item): item is string => Boolean(item));
238250

239251
for (const candidate of candidates) {
240-
const entry = join(dir, candidate);
252+
const entry = resolve(dir, candidate);
253+
if (!isInsideDirectory(dir, entry)) {
254+
throw new Error(`Plugin entrypoint escapes plugin directory: ${candidate}`);
255+
}
241256
if (existsSync(entry)) return entry;
242257
}
243258
return null;
244259
}
245260

261+
function exportsObjectEntrypoints(value: PluginManifest["exports"]): string[] {
262+
if (!value || typeof value === "string") return [];
263+
const results: string[] = [];
264+
const visit = (node: unknown, depth: number) => {
265+
if (depth > 3 || !node) return;
266+
if (typeof node === "string") {
267+
results.push(node);
268+
return;
269+
}
270+
if (typeof node !== "object") return;
271+
const record = node as Record<string, unknown>;
272+
for (const key of [".", "import", "default", "node", "require"]) {
273+
if (record[key] !== undefined) visit(record[key], depth + 1);
274+
}
275+
};
276+
visit(value, 0);
277+
return results;
278+
}
279+
280+
function isInsideDirectory(dir: string, entry: string): boolean {
281+
const rel = relative(resolve(dir), entry);
282+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
283+
}
284+
246285
async function pluginContext(
247286
cwd: string,
248287
scan: ScanResult,

tests/errors.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ describe("centralized error system", () => {
5151
expect(classifyAIProviderError(new Error("invalid json response")).code).toBe("AI_PROVIDER_PROTOCOL_ERROR");
5252
});
5353

54+
it("classifies structured provider errors from SDK status and code fields", () => {
55+
expect(classifyAIProviderError(Object.assign(new Error("provider refused request"), { status: 401 })).code)
56+
.toBe("AI_PROVIDER_AUTH_FAILED");
57+
expect(classifyAIProviderError(Object.assign(new Error("too many requests"), { status: 429 })).code)
58+
.toBe("AI_PROVIDER_RATE_LIMITED");
59+
expect(classifyAIProviderError(Object.assign(new Error("too many requests"), { status: 429, code: "insufficient_quota" })).code)
60+
.toBe("AI_PROVIDER_QUOTA_EXHAUSTED");
61+
expect(classifyAIProviderError({ response: { status: 502, statusText: "Bad Gateway" } }).code)
62+
.toBe("AI_PROVIDER_UNAVAILABLE");
63+
});
64+
5465
it("sanitizes common secret formats", () => {
5566
const value = sanitizeSecret("OPENAI_API_KEY=sk-abcdef1234567890 GITHUB_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz");
5667

tests/plugin-runtime.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
33
import { join } from "path";
44
import { tmpdir } from "os";
55
import { env } from "process";
6-
import { applyPluginPlanners, runPluginDoctorChecks, tryRunPluginCommand } from "../src/plugins/runtime.js";
6+
import { applyPluginPlanners, loadEnabledPlugins, runPluginDoctorChecks, tryRunPluginCommand } from "../src/plugins/runtime.js";
77
import { saveConfig } from "../src/state/config.js";
88
import type { ScanResult } from "../src/scanner/index.js";
99
import type { SetupStep } from "../src/ai/planner.js";
@@ -96,4 +96,95 @@ describe("plugin runtime", () => {
9696
expect(commandHandled).toBe(true);
9797
expect(logs.join("\n")).toContain("team command one");
9898
});
99+
100+
it("loads plugins that expose an exports object entrypoint", async () => {
101+
const pluginDir = join(tempDir, ".setupr", "plugins", "exported");
102+
await mkdir(join(pluginDir, "build"), { recursive: true });
103+
await writeFile(join(pluginDir, "package.json"), JSON.stringify({
104+
name: "setupr-plugin-exported",
105+
version: "0.1.0",
106+
type: "module",
107+
exports: { ".": { import: "./build/plugin.js" } },
108+
setupr: { apiVersion: "1" },
109+
}));
110+
await writeFile(join(pluginDir, "build", "plugin.js"), `
111+
export default {
112+
name: "setupr-plugin-exported",
113+
apiVersion: "1",
114+
commands: [{ name: "exported-ok", summary: "OK", run(context) { context.log("exported command"); } }],
115+
};
116+
`);
117+
await saveConfig(configWithPlugins([
118+
{ name: "setupr-plugin-exported", version: "0.1.0", enabled: true, source: ".setupr/plugins/exported" },
119+
]));
120+
121+
const loaded = await loadEnabledPlugins(tempDir);
122+
123+
expect(loaded.diagnostics).toContainEqual(expect.objectContaining({ name: "setupr-plugin-exported", status: "loaded" }));
124+
expect(loaded.plugins.map((item) => item.plugin.name)).toContain("setupr-plugin-exported");
125+
});
126+
127+
it("rejects plugin entrypoints that escape the plugin directory", async () => {
128+
const pluginDir = join(tempDir, ".setupr", "plugins", "escape");
129+
await mkdir(pluginDir, { recursive: true });
130+
await writeFile(join(tempDir, ".setupr", "plugins", "evil.js"), "export default { name: 'evil', apiVersion: '1' };\n");
131+
await writeFile(join(pluginDir, "package.json"), JSON.stringify({
132+
name: "setupr-plugin-escape",
133+
version: "0.1.0",
134+
type: "module",
135+
main: "../evil.js",
136+
setupr: { apiVersion: "1" },
137+
}));
138+
await saveConfig(configWithPlugins([
139+
{ name: "setupr-plugin-escape", version: "0.1.0", enabled: true, source: ".setupr/plugins/escape" },
140+
]));
141+
142+
const loaded = await loadEnabledPlugins(tempDir);
143+
144+
expect(loaded.plugins).toHaveLength(0);
145+
expect(loaded.diagnostics[0]).toMatchObject({ name: "setupr-plugin-escape", status: "failed" });
146+
expect(loaded.diagnostics[0].message).toContain("escapes plugin directory");
147+
});
148+
149+
it("turns throwing plugin commands into structured plugin errors", async () => {
150+
const pluginDir = join(tempDir, ".setupr", "plugins", "thrower");
151+
await mkdir(pluginDir, { recursive: true });
152+
await writeFile(join(pluginDir, "package.json"), JSON.stringify({
153+
name: "setupr-plugin-thrower",
154+
version: "0.1.0",
155+
type: "module",
156+
main: "index.js",
157+
setupr: { apiVersion: "1" },
158+
}));
159+
await writeFile(join(pluginDir, "index.js"), `
160+
export default {
161+
name: "setupr-plugin-thrower",
162+
apiVersion: "1",
163+
commands: [{ name: "explode", summary: "Fail", run() { throw new Error("plugin boom"); } }],
164+
};
165+
`);
166+
await saveConfig(configWithPlugins([
167+
{ name: "setupr-plugin-thrower", version: "0.1.0", enabled: true, source: ".setupr/plugins/thrower" },
168+
]));
169+
170+
await expect(tryRunPluginCommand({ cwd: tempDir, command: "explode", args: [] }))
171+
.rejects.toMatchObject({ code: "PLUGIN_LOAD_FAILED", details: expect.arrayContaining(["Plugin: setupr-plugin-thrower", "plugin boom"]) });
172+
});
99173
});
174+
175+
function configWithPlugins(plugins: Array<{ name: string; version: string; enabled: boolean; source: string }>) {
176+
return {
177+
ai: { enabled: true, timeoutMs: 30000, maxRetries: 3, retryDelayMs: 1000, rateLimitPerMinute: 20 },
178+
preferences: {
179+
theme: "dark",
180+
confirmBeforeInstall: true,
181+
autoUpdate: false,
182+
telemetry: false,
183+
defaultBranch: "main",
184+
commitConvention: "conventional",
185+
ciPlatform: "auto",
186+
},
187+
plugins,
188+
remembered: {},
189+
};
190+
}

0 commit comments

Comments
 (0)