mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-16 03:42:21 +03:00
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura. **A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos. A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis). **A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura. Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
296 lines
9.9 KiB
TypeScript
296 lines
9.9 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-skills-executor-"));
|
|
const TEST_DATA_DIR = path.join(TEST_ROOT, "data");
|
|
const TEST_PLUGINS_DIR = path.join(TEST_ROOT, "plugins");
|
|
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
|
const ORIGINAL_PLUGINS_DIR = process.env.OMNIROUTE_PLUGINS_DIR;
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
fs.mkdirSync(TEST_PLUGINS_DIR, { recursive: true });
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.OMNIROUTE_PLUGINS_DIR = TEST_PLUGINS_DIR;
|
|
|
|
const coreDb = await import("../../src/lib/db/core.ts");
|
|
const settingsDb = await import("../../src/lib/db/settings.ts");
|
|
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
|
|
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
|
|
|
|
function resetSkillsRuntime() {
|
|
skillRegistry["registeredSkills"].clear();
|
|
skillRegistry["versionCache"].clear();
|
|
skillExecutor["handlers"].clear();
|
|
skillExecutor.setTimeout(50);
|
|
skillExecutor.setMaxRetries(3);
|
|
}
|
|
|
|
async function resetStorage() {
|
|
resetSkillsRuntime();
|
|
coreDb.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
async function registerEchoSkill(overrides = {}) {
|
|
return skillRegistry.register({
|
|
name: "echo",
|
|
version: "1.0.0",
|
|
description: "echoes input",
|
|
schema: { input: { value: "string" }, output: { echoed: "string" } },
|
|
handler: "echo-handler",
|
|
enabled: true,
|
|
apiKeyId: "key-a",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test.beforeEach(async () => {
|
|
await resetStorage();
|
|
});
|
|
|
|
test.after(() => {
|
|
resetSkillsRuntime();
|
|
coreDb.resetDbInstance();
|
|
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
|
|
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
|
if (ORIGINAL_PLUGINS_DIR === undefined) delete process.env.OMNIROUTE_PLUGINS_DIR;
|
|
else process.env.OMNIROUTE_PLUGINS_DIR = ORIGINAL_PLUGINS_DIR;
|
|
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
test("skillExecutor executes a registered handler and persists execution history", async () => {
|
|
const skill = await registerEchoSkill();
|
|
|
|
skillExecutor.registerHandler("echo-handler", async (input, context) => ({
|
|
echoed: `${input.value}:${context.apiKeyId}:${context.sessionId}`,
|
|
}));
|
|
|
|
const execution = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "hello" },
|
|
{ apiKeyId: "key-a", sessionId: "session-1" }
|
|
);
|
|
|
|
assert.equal(execution.skillId, skill.id);
|
|
assert.equal(execution.status, "success");
|
|
assert.deepEqual(execution.output, { echoed: "hello:key-a:session-1" });
|
|
assert.equal(execution.errorMessage, null);
|
|
assert.equal(typeof execution.durationMs, "number");
|
|
|
|
const stored = skillExecutor.getExecution(execution.id);
|
|
assert.equal(stored?.status, "success");
|
|
assert.deepEqual(stored?.output, { echoed: "hello:key-a:session-1" });
|
|
|
|
const listed = skillExecutor.listExecutions("key-a");
|
|
assert.equal(listed.length, 1);
|
|
assert.equal(listed[0].id, execution.id);
|
|
});
|
|
|
|
test("skillExecutor sanitizes failed outputs and nested error subtrees before persistence", async () => {
|
|
await registerEchoSkill();
|
|
const hostile =
|
|
"tool failed access_token=skill-output-secret at /srv/private/skill-output.ts\n" +
|
|
" at run (/srv/private/skill-output.ts:8:2)";
|
|
|
|
skillExecutor.registerHandler("echo-handler", async () => ({
|
|
success: false,
|
|
status: 502,
|
|
statusText: hostile,
|
|
headers: { authorization: "Bearer skill-output-secret" },
|
|
body: hostile,
|
|
stdout: hostile,
|
|
stderr: hostile,
|
|
}));
|
|
|
|
const failedOutput = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "failure" },
|
|
{ apiKeyId: "key-a", sessionId: "session-output" }
|
|
);
|
|
const storedFailure = skillExecutor.getExecution(failedOutput.id);
|
|
const failureSerialized = JSON.stringify({ failedOutput, storedFailure });
|
|
|
|
assert.equal((failedOutput.output as Record<string, unknown>)?.status, 502);
|
|
assert.doesNotMatch(
|
|
failureSerialized,
|
|
/skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i
|
|
);
|
|
|
|
skillExecutor.registerHandler("echo-handler", async () => ({
|
|
success: true,
|
|
payload: {
|
|
value: "preserve me",
|
|
error: { message: hostile },
|
|
},
|
|
warning: hostile,
|
|
}));
|
|
const successfulOutput = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "success" },
|
|
{ apiKeyId: "key-a", sessionId: "session-success" }
|
|
);
|
|
const storedSuccess = skillExecutor.getExecution(successfulOutput.id);
|
|
const successSerialized = JSON.stringify({ successfulOutput, storedSuccess });
|
|
|
|
assert.equal(
|
|
((successfulOutput.output as Record<string, unknown>)?.payload as Record<string, unknown>)
|
|
?.value,
|
|
"preserve me"
|
|
);
|
|
assert.doesNotMatch(
|
|
successSerialized,
|
|
/skill-output-secret|srv\/private|skill-output\.ts|\bat run\b/i
|
|
);
|
|
});
|
|
|
|
test("skillExecutor treats failure discriminators and aliased error objects as boundary failures", async () => {
|
|
await registerEchoSkill();
|
|
const hostile = "Bearer skill-discriminator-secret at /srv/private/skill-discriminator.ts:8:2";
|
|
|
|
for (const result of [
|
|
{ type: "error", message: hostile },
|
|
{ status: "failed", reason: hostile },
|
|
]) {
|
|
skillExecutor.registerHandler("echo-handler", async () => result);
|
|
const execution = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "discriminated-failure" },
|
|
{ apiKeyId: "key-a", sessionId: "session-discriminated" }
|
|
);
|
|
const stored = skillExecutor.getExecution(execution.id);
|
|
assert.equal(execution.status, "error");
|
|
assert.equal(stored?.status, "error");
|
|
assert.doesNotMatch(
|
|
JSON.stringify({ execution, stored }),
|
|
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
|
);
|
|
}
|
|
|
|
const shared = { message: hostile };
|
|
skillExecutor.registerHandler("echo-handler", async () => ({
|
|
success: true,
|
|
payload: { error: shared },
|
|
alias: shared,
|
|
}));
|
|
const aliased = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "alias" },
|
|
{ apiKeyId: "key-a", sessionId: "session-alias" }
|
|
);
|
|
assert.equal(aliased.status, "success");
|
|
assert.doesNotMatch(
|
|
JSON.stringify({ aliased, stored: skillExecutor.getExecution(aliased.id) }),
|
|
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
|
);
|
|
|
|
const cyclic: Record<string, unknown> = { success: true, error: shared };
|
|
cyclic.self = cyclic;
|
|
skillExecutor.registerHandler("echo-handler", async () => cyclic);
|
|
const cycleSafe = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "cycle" },
|
|
{ apiKeyId: "key-a", sessionId: "session-cycle" }
|
|
);
|
|
assert.equal(cycleSafe.status, "success");
|
|
assert.doesNotThrow(() => JSON.stringify(cycleSafe.output));
|
|
assert.doesNotMatch(
|
|
JSON.stringify({ cycleSafe, stored: skillExecutor.getExecution(cycleSafe.id) }),
|
|
/skill-discriminator-secret|srv\/private|skill-discriminator\.ts/i
|
|
);
|
|
});
|
|
|
|
test("skillExecutor blocks execution when Skills are disabled in settings", async () => {
|
|
await registerEchoSkill();
|
|
await settingsDb.updateSettings({ skillsEnabled: false });
|
|
|
|
await assert.rejects(
|
|
skillExecutor.execute("echo@1.0.0", { value: "hello" }, { apiKeyId: "key-a" }),
|
|
/Skills execution is disabled/
|
|
);
|
|
});
|
|
|
|
test("skillExecutor records handler lookup failures as errored executions", async () => {
|
|
await registerEchoSkill();
|
|
|
|
await assert.rejects(
|
|
skillExecutor.execute("echo@1.0.0", { value: "hello" }, { apiKeyId: "key-a" }),
|
|
/Handler not found: echo-handler/
|
|
);
|
|
|
|
const executions = skillExecutor.listExecutions("key-a");
|
|
assert.equal(executions.length, 1);
|
|
assert.equal(executions[0].status, "error");
|
|
assert.match(executions[0].errorMessage, /Handler not found/);
|
|
assert.equal(executions[0].output, null);
|
|
});
|
|
|
|
test("skillExecutor records disabled skills and missing skills as direct failures", async () => {
|
|
await registerEchoSkill({ enabled: false });
|
|
|
|
await assert.rejects(
|
|
skillExecutor.execute("echo@1.0.0", { value: "hello" }, { apiKeyId: "key-a" }),
|
|
/Skill is disabled/
|
|
);
|
|
await assert.rejects(
|
|
skillExecutor.execute("missing@1.0.0", { value: "hello" }, { apiKeyId: "key-a" }),
|
|
/Skill not found/
|
|
);
|
|
|
|
assert.equal(skillExecutor.listExecutions("key-a").length, 0);
|
|
});
|
|
|
|
test("skillExecutor turns handler errors and timeouts into error executions", async () => {
|
|
await registerEchoSkill();
|
|
|
|
skillExecutor.registerHandler("echo-handler", async () => {
|
|
throw new Error(
|
|
"handler exploded access_token=skill-db-secret at /srv/private/skill-executor.ts\n" +
|
|
" at execute (/srv/private/skill-executor.ts:21:5)"
|
|
);
|
|
});
|
|
|
|
const failed = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "boom" },
|
|
{ apiKeyId: "key-a", sessionId: "session-2" }
|
|
);
|
|
|
|
assert.equal(failed.status, "error");
|
|
assert.equal(failed.output, null);
|
|
assert.match(failed.errorMessage, /handler exploded/);
|
|
assert.doesNotMatch(
|
|
String(failed.errorMessage),
|
|
/skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i
|
|
);
|
|
const storedFailure = skillExecutor.getExecution(failed.id);
|
|
assert.match(String(storedFailure?.errorMessage), /handler exploded/);
|
|
assert.doesNotMatch(
|
|
String(storedFailure?.errorMessage),
|
|
/skill-db-secret|srv\/private|skill-executor\.ts|\bat execute\b/i
|
|
);
|
|
|
|
skillExecutor.registerHandler(
|
|
"echo-handler",
|
|
async () =>
|
|
new Promise((resolve) => {
|
|
setTimeout(() => resolve({ late: true }), 25);
|
|
})
|
|
);
|
|
skillExecutor.setTimeout(5);
|
|
skillExecutor.setMaxRetries(7);
|
|
|
|
const timedOut = await skillExecutor.execute(
|
|
"echo@1.0.0",
|
|
{ value: "slow" },
|
|
{ apiKeyId: "key-a", sessionId: "session-3" }
|
|
);
|
|
|
|
assert.equal(skillExecutor["maxRetries"], 7);
|
|
assert.equal(timedOut.status, "error");
|
|
assert.equal(timedOut.output, null);
|
|
assert.match(timedOut.errorMessage, /timed out/i);
|
|
});
|