mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-15 11:22:15 +03:00
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, e a suíte vitest:ui completa (2149) verde. Sobre esta PR especificamente: rodei os **23 arquivos de teste** que ela toca sobre o tip final, depois do merge da base — **392/392**. A migration `174_server_tool_executions.sql` não colide (o tip está em 173, e você já a renumerou em `c35f0fd7`). O dono foi consultado antes do merge, porque o loop está atrás da flag `SERVER_OWNED_TOOL_LOOP_ENABLED` mas o primeiro send não-streaming mudou de dono sem flag, e a verificação manual em combo com Memory continuava desmarcada. A condição dele foi: entra se os testes focados passarem aqui. Passaram. O lock de passthrough (`fetchCalls.length === 1`) é a parte que mais me convenceu — o double-dispatch que um `if (stream)` em volta do send existente causaria é exatamente o tipo de regressão que não aparece em teste de comportamento, só em contagem de chamada. **Três ajustes meus na sua branch:** 1. `tests/unit/chatcore-stream-error-result.test.ts` procurava `"const legResult = await runNonStreamingProviderLeg"`, mas o seu commit final `6077b9dd` passou a reatribuir `legResult` e trocou para `let`. O guard falhava na sua própria branch (confirmei que o arquivo e o `chatCore.ts` eram byte-idênticos ao head da PR, então não era efeito da leva). Passou a aceitar `const|let` — a intenção do guard é o try/catch em volta da chamada, não a palavra-chave. 2. `tests/integration/skills-pipeline.test.ts` foi de 1156 para 1338 linhas e estourou o `testCap` de 1200. Segui o mesmo caminho que você já tinha tomado em `a1d2d20d` para os testes unitários: extraí os três casos do server-owned tool loop para `tests/integration/server-owned-tool-loop-pipeline.test.ts` (259 linhas), com instância própria do harness. O glob `tests/integration/*.test.ts` pega o arquivo novo sem registro adicional. 3/3 verdes isolados. 3. O arquivo novo herdou cinco `any` do original — que só passavam por estarem congelados no `eslint-suppressions.json` sob o nome antigo. Tipei como `Record<string, unknown>`. E `tests/unit/non-streaming-finalization.test.ts` tinha dois argumentos não usados em `trackPendingRequest`, agora prefixados com `_`. Nada disso toca produção nem enfraquece asserção.
787 lines
25 KiB
TypeScript
787 lines
25 KiB
TypeScript
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import { createRequire } from "node:module";
|
|
|
|
const require_ = createRequire(import.meta.url);
|
|
const BetterSqlite3 = require_("better-sqlite3") as typeof import("better-sqlite3");
|
|
|
|
import { createBetterSqliteAdapter } from "../../src/lib/db/adapters/betterSqliteAdapter";
|
|
import type { SqliteAdapter } from "../../src/lib/db/adapters/types";
|
|
|
|
import {
|
|
claimServerToolExecution,
|
|
finalizeServerToolExecution,
|
|
} from "../../src/lib/db/skillExecutionFence";
|
|
import { runWithServerToolFence } from "../../src/lib/skills/toolExecutionFence";
|
|
|
|
// Minimal fixture schema — only what tests need; no SCHEMA_SQL import from core.ts
|
|
const FIXTURE_SCHEMA = `
|
|
CREATE TABLE IF NOT EXISTS server_tool_executions (
|
|
id TEXT PRIMARY KEY,
|
|
api_key_id TEXT NOT NULL,
|
|
request_identity TEXT NOT NULL,
|
|
tool_call_id TEXT NOT NULL,
|
|
tool_name TEXT NOT NULL,
|
|
input_digest TEXT NOT NULL,
|
|
output TEXT,
|
|
status TEXT NOT NULL CHECK(status IN ('running', 'success', 'error', 'timeout')),
|
|
error_message TEXT,
|
|
duration_ms INTEGER,
|
|
claim_expires_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(api_key_id, request_identity, tool_call_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_server_tool_executions_status_expiry
|
|
ON server_tool_executions(status, claim_expires_at);
|
|
CREATE INDEX IF NOT EXISTS idx_server_tool_executions_created
|
|
ON server_tool_executions(created_at);
|
|
`;
|
|
|
|
function makeTempDb(): {
|
|
adapter: SqliteAdapter;
|
|
dir: string;
|
|
raw: import("better-sqlite3").Database;
|
|
} {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fence-test-"));
|
|
const dbPath = path.join(dir, "test.db");
|
|
const raw = new BetterSqlite3(dbPath);
|
|
raw.pragma("journal_mode = WAL");
|
|
raw.pragma("busy_timeout = 2000");
|
|
raw.exec(FIXTURE_SCHEMA);
|
|
const adapter = createBetterSqliteAdapter(raw);
|
|
return { adapter, dir, raw };
|
|
}
|
|
|
|
function makeSecondAdapter(dir: string): {
|
|
adapter: SqliteAdapter;
|
|
raw: import("better-sqlite3").Database;
|
|
} {
|
|
const dbPath = path.join(dir, "test.db");
|
|
const raw = new BetterSqlite3(dbPath);
|
|
raw.pragma("journal_mode = WAL");
|
|
raw.pragma("busy_timeout = 2000");
|
|
const adapter = createBetterSqliteAdapter(raw);
|
|
return { adapter, raw };
|
|
}
|
|
|
|
function cleanup(raw: import("better-sqlite3").Database, dir: string) {
|
|
raw.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
|
|
const BASE_INPUT = {
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req-id:body-hash",
|
|
toolCallId: "call-1",
|
|
toolName: "memory_search",
|
|
inputDigest: "abc123",
|
|
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
};
|
|
|
|
const BASE_FINALIZE = {
|
|
executionId: "",
|
|
status: "success" as const,
|
|
output: { result: "ok" },
|
|
errorMessage: null,
|
|
durationMs: 100,
|
|
};
|
|
|
|
// ── Defect 1: Lease expiry compares STORED claim_expires_at, not input ──
|
|
|
|
test("claim: stored expired lease → unknown even if retry supplies future lease", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const pastLease = new Date(Date.now() - 60_000).toISOString();
|
|
const claim1 = claimServerToolExecution(
|
|
{ ...BASE_INPUT, leaseExpiresAt: pastLease },
|
|
adapter,
|
|
Date.now()
|
|
);
|
|
assert.equal(claim1.kind, "claimed");
|
|
const futureLease = new Date(Date.now() + 300_000).toISOString();
|
|
const claim2 = claimServerToolExecution(
|
|
{ ...BASE_INPUT, leaseExpiresAt: futureLease },
|
|
adapter,
|
|
Date.now()
|
|
);
|
|
assert.equal(claim2.kind, "unknown", "must compare stored lease, not input lease");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("claim: stored future lease with expired retry → in_progress (not unknown)", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const futureLease = new Date(Date.now() + 300_000).toISOString();
|
|
const claim1 = claimServerToolExecution(
|
|
{ ...BASE_INPUT, leaseExpiresAt: futureLease },
|
|
adapter,
|
|
Date.now()
|
|
);
|
|
assert.equal(claim1.kind, "claimed");
|
|
const pastLease = new Date(Date.now() - 10_000).toISOString();
|
|
const claim2 = claimServerToolExecution(
|
|
{ ...BASE_INPUT, leaseExpiresAt: pastLease },
|
|
adapter,
|
|
Date.now()
|
|
);
|
|
assert.equal(claim2.kind, "in_progress", "stored lease is future, so should be in_progress");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
// ── Basic claim/replay tests ──
|
|
|
|
test("claim: first claim returns claimed", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const result = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(result.kind, "claimed");
|
|
assert.ok(typeof result.executionId === "string" && result.executionId.length > 0);
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("claim: terminal row (success) with same identity returns replay", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim1 = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim1.kind, "claimed");
|
|
finalizeServerToolExecution({ ...BASE_FINALIZE, executionId: claim1.executionId }, adapter);
|
|
const claim2 = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim2.kind, "replay");
|
|
if (claim2.kind === "replay") {
|
|
assert.equal(claim2.status, "success");
|
|
assert.deepEqual(claim2.output, { result: "ok" });
|
|
}
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("claim: terminal row (error) replay preserves error status and message", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim1 = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim1.kind, "claimed");
|
|
finalizeServerToolExecution(
|
|
{
|
|
...BASE_FINALIZE,
|
|
executionId: claim1.executionId,
|
|
status: "error",
|
|
output: null,
|
|
errorMessage: "tool failed",
|
|
durationMs: 42,
|
|
},
|
|
adapter
|
|
);
|
|
const claim2 = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim2.kind, "replay");
|
|
if (claim2.kind === "replay") {
|
|
assert.equal(claim2.status, "error");
|
|
assert.equal(claim2.errorMessage, "tool failed");
|
|
}
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("claim: same key but different name returns identity_conflict", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
claimServerToolExecution(BASE_INPUT, adapter);
|
|
const claim2 = claimServerToolExecution({ ...BASE_INPUT, toolName: "different_tool" }, adapter);
|
|
assert.equal(claim2.kind, "identity_conflict");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("claim: same key but different inputDigest returns identity_conflict", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
claimServerToolExecution(BASE_INPUT, adapter);
|
|
const claim2 = claimServerToolExecution(
|
|
{ ...BASE_INPUT, inputDigest: "different_digest" },
|
|
adapter
|
|
);
|
|
assert.equal(claim2.kind, "identity_conflict");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
// ── Defect 2: Cross-handle poll test with gate-based concurrency ──
|
|
|
|
test("fence: cross-handle poll — B sees in_progress, then A finalizes, then B replays", async () => {
|
|
const { adapter: adapterA, dir, raw: rawA } = makeTempDb();
|
|
const { adapter: adapterB, raw: rawB } = makeSecondAdapter(dir);
|
|
let fakeTime = 1_000_000;
|
|
const now = () => fakeTime;
|
|
const sleep = async (ms: number) => {
|
|
fakeTime += ms;
|
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
};
|
|
|
|
// Gate: A signals when it starts, B signals when it observes in_progress
|
|
let aStartedResolve!: () => void;
|
|
const aStarted = new Promise<void>((r) => {
|
|
aStartedResolve = r;
|
|
});
|
|
let bObservedResolve!: () => void;
|
|
const bObserved = new Promise<void>((r) => {
|
|
bObservedResolve = r;
|
|
});
|
|
|
|
let handlerCallCount = 0;
|
|
const handler = async () => {
|
|
handlerCallCount++;
|
|
aStartedResolve();
|
|
// Wait until B has observed in_progress before finalizing
|
|
await bObserved;
|
|
return "handler-result";
|
|
};
|
|
|
|
const sharedArgs = { q: "test-query" };
|
|
|
|
// Handle A claims via fence wrapper
|
|
const pA = runWithServerToolFence({
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req-id:body-hash",
|
|
toolCallId: "call-1",
|
|
toolName: "memory_search",
|
|
arguments: sharedArgs,
|
|
leaseDurationMs: 60_000,
|
|
execute: handler,
|
|
now,
|
|
sleep,
|
|
db: adapterA,
|
|
});
|
|
|
|
// Wait for A to start executing
|
|
await aStarted;
|
|
|
|
// Handle B calls the fence wrapper — should find running row, observe in_progress
|
|
const pB = runWithServerToolFence({
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req-id:body-hash",
|
|
toolCallId: "call-1",
|
|
toolName: "memory_search",
|
|
arguments: sharedArgs,
|
|
leaseDurationMs: 60_000,
|
|
execute: async () => "should-not-run",
|
|
now,
|
|
sleep,
|
|
db: adapterB,
|
|
});
|
|
|
|
// Injected sleep proves B entered polling before A may finalize.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
assert.ok(fakeTime > 1_000_000, "B must poll before A finalizes");
|
|
bObservedResolve();
|
|
|
|
const [, fenceResult] = await Promise.all([pA, pB]);
|
|
assert.equal(handlerCallCount, 1, "handler executes exactly once");
|
|
assert.equal(fenceResult.kind, "replayed");
|
|
if (fenceResult.kind === "replayed") {
|
|
assert.equal(fenceResult.status, "success");
|
|
}
|
|
rawA.close();
|
|
rawB.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fence: in_progress poll returns in_progress when not finalized within deadline", async () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
let fakeTime = 1_000_000;
|
|
const now = () => fakeTime;
|
|
const sleep = async (ms: number) => {
|
|
fakeTime += ms;
|
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
};
|
|
|
|
const sharedArgs = { q: "test" };
|
|
const digest = require_("crypto")
|
|
.createHash("sha256")
|
|
.update(JSON.stringify(sharedArgs))
|
|
.digest("hex");
|
|
|
|
// Insert a running row directly (simulates another process that claimed but hasn't finalized)
|
|
raw.exec(`
|
|
INSERT INTO server_tool_executions
|
|
(id, api_key_id, request_identity, tool_call_id, tool_name, input_digest, status, claim_expires_at)
|
|
VALUES ('ext-id', 'key-1', 'key-1:req:body', 'call-1', 'tool_a', '${digest}', 'running', datetime('now', '+300 seconds'))
|
|
`);
|
|
|
|
const result = await runWithServerToolFence({
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req:body",
|
|
toolCallId: "call-1",
|
|
toolName: "tool_a",
|
|
arguments: sharedArgs,
|
|
leaseDurationMs: 60_000,
|
|
execute: async () => "should-not-run",
|
|
now,
|
|
sleep,
|
|
db: adapter,
|
|
});
|
|
|
|
assert.equal(result.kind, "in_progress");
|
|
assert.ok(fakeTime >= 1_000_000 + 2_000, "time should have advanced by poll budget");
|
|
cleanup(raw, dir);
|
|
});
|
|
|
|
// ── Defect 3: Duration test — drives runWithServerToolFence, advances injected clock ──
|
|
|
|
test("finalize: duration_ms persisted exactly via fence wrapper (success)", async () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
let fakeTime = 10_000;
|
|
const now = () => fakeTime;
|
|
const sleep = async (ms: number) => {
|
|
fakeTime += ms;
|
|
};
|
|
|
|
const result = await runWithServerToolFence({
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req:body",
|
|
toolCallId: "call-1",
|
|
toolName: "tool_a",
|
|
arguments: { x: 1 },
|
|
leaseDurationMs: 60_000,
|
|
execute: async (_execId) => {
|
|
fakeTime += 42;
|
|
return "done";
|
|
},
|
|
now,
|
|
sleep,
|
|
db: adapter,
|
|
});
|
|
|
|
assert.equal(result.kind, "executed");
|
|
// Query duration_ms from DB — wrapper must have calculated it, not hardcoded 0
|
|
const row = raw
|
|
.prepare("SELECT duration_ms, status FROM server_tool_executions WHERE status = 'success'")
|
|
.get() as { duration_ms: number | null; status: string };
|
|
assert.equal(row.duration_ms, 42, "duration_ms must be exactly 42 via injected clock");
|
|
assert.equal(row.status, "success");
|
|
cleanup(raw, dir);
|
|
});
|
|
|
|
test("finalize: duration_ms persisted exactly via fence wrapper (error)", async () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
let fakeTime = 10_000;
|
|
const now = () => fakeTime;
|
|
const sleep = async (ms: number) => {
|
|
fakeTime += ms;
|
|
};
|
|
|
|
await assert.rejects(
|
|
runWithServerToolFence({
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req:body",
|
|
toolCallId: "call-1",
|
|
toolName: "tool_a",
|
|
arguments: { x: 1 },
|
|
leaseDurationMs: 60_000,
|
|
execute: async () => {
|
|
fakeTime += 17;
|
|
throw new Error("boom");
|
|
},
|
|
now,
|
|
sleep,
|
|
db: adapter,
|
|
}),
|
|
/boom/,
|
|
"handler error must propagate"
|
|
);
|
|
|
|
const row = raw
|
|
.prepare("SELECT duration_ms, status FROM server_tool_executions WHERE status = 'error'")
|
|
.get() as { duration_ms: number | null; status: string };
|
|
assert.equal(row.duration_ms, 17, "duration_ms must be exactly 17 for error via injected clock");
|
|
assert.equal(row.status, "error");
|
|
cleanup(raw, dir);
|
|
});
|
|
|
|
// ── Defect 4: Rejected in-process active Promise → joiner gets replayed ──
|
|
|
|
test("fence: rejected in-process promise — joiner gets replayed with error status", async () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
let fakeTime = 1_000_000;
|
|
const now = () => fakeTime;
|
|
const sleep = async (ms: number) => {
|
|
fakeTime += ms;
|
|
await new Promise<void>((r) => setTimeout(r, 0));
|
|
};
|
|
|
|
// Gate for concurrency: A signals started, B signals it observed in_progress
|
|
let aStartedResolve!: () => void;
|
|
const aStarted = new Promise<void>((r) => {
|
|
aStartedResolve = r;
|
|
});
|
|
let bObservedResolve!: () => void;
|
|
const bObserved = new Promise<void>((r) => {
|
|
bObservedResolve = r;
|
|
});
|
|
|
|
let handlerCalled = false;
|
|
const failingHandler = async () => {
|
|
handlerCalled = true;
|
|
aStartedResolve();
|
|
await bObserved;
|
|
throw new Error("handler crashed");
|
|
};
|
|
|
|
// Handle A claims via fence — handler will throw, wrapper resolves with error status
|
|
const pA = runWithServerToolFence({
|
|
apiKeyId: "key-2",
|
|
requestIdentity: "key-2:req:body",
|
|
toolCallId: "call-x",
|
|
toolName: "tool_a",
|
|
arguments: {},
|
|
leaseDurationMs: 60_000,
|
|
execute: failingHandler,
|
|
now,
|
|
sleep,
|
|
db: adapter,
|
|
});
|
|
|
|
await aStarted;
|
|
|
|
// Handle B immediately tries same key — gets in_progress, polls the active promise
|
|
const pB = runWithServerToolFence({
|
|
apiKeyId: "key-2",
|
|
requestIdentity: "key-2:req:body",
|
|
toolCallId: "call-x",
|
|
toolName: "tool_a",
|
|
arguments: {},
|
|
leaseDurationMs: 60_000,
|
|
execute: async () => {
|
|
throw new Error("should-not-run");
|
|
},
|
|
now,
|
|
sleep,
|
|
db: adapter,
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
bObservedResolve();
|
|
|
|
const [rA, rB] = await Promise.allSettled([pA, pB]);
|
|
assert.ok(handlerCalled, "handler should have been called");
|
|
assert.equal(rA.status, "rejected", "handle A should reject because handler threw");
|
|
assert.equal(rB.status, "fulfilled");
|
|
if (rB.status === "fulfilled") {
|
|
assert.equal(rB.value.kind, "replayed", "joiner must get replayed for terminal error");
|
|
if (rB.value.kind === "replayed") {
|
|
assert.equal(rB.value.status, "error", "replayed status must be error");
|
|
assert.equal(rB.value.errorMessage, "handler crashed");
|
|
}
|
|
}
|
|
cleanup(raw, dir);
|
|
});
|
|
|
|
// ── Defect 6: isUniqueConstraintError rejects non-UNIQUE constraints ──
|
|
|
|
test("isUniqueConstraintError: NOT NULL error propagates through claimServerToolExecution", () => {
|
|
const { dir, raw } = makeTempDb();
|
|
try {
|
|
// Build a fake adapter that wraps the real one but makes INSERT throw NOT NULL
|
|
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "fence-fake-"));
|
|
const fakeRaw = new BetterSqlite3(path.join(realDir, "test.db"));
|
|
fakeRaw.pragma("journal_mode = WAL");
|
|
fakeRaw.exec(FIXTURE_SCHEMA);
|
|
const fake = createBetterSqliteAdapter(fakeRaw);
|
|
|
|
// Proxy: intercept INSERT INTO server_tool_executions and inject NOT NULL error
|
|
const proxyAdapter = new Proxy(fake, {
|
|
get(target, prop) {
|
|
if (prop === "prepare") {
|
|
return (sql: string) => {
|
|
const stmt = (target.prepare as Function)(sql);
|
|
if (/INSERT INTO server_tool_executions/i.test(sql)) {
|
|
return {
|
|
...stmt,
|
|
run: (..._args: unknown[]) => {
|
|
const err = new Error("NOT NULL constraint failed") as Error & { code: string };
|
|
err.code = "SQLITE_CONSTRAINT_NOTNULL";
|
|
throw err;
|
|
},
|
|
};
|
|
}
|
|
return stmt;
|
|
};
|
|
}
|
|
return (target as Record<string, unknown>)[prop as string];
|
|
},
|
|
}) as SqliteAdapter;
|
|
|
|
assert.throws(
|
|
() => {
|
|
claimServerToolExecution(BASE_INPUT, proxyAdapter);
|
|
},
|
|
(err: unknown) => {
|
|
return (
|
|
err instanceof Error &&
|
|
err.message.includes("NOT NULL") &&
|
|
(err as { code?: string }).code === "SQLITE_CONSTRAINT_NOTNULL"
|
|
);
|
|
},
|
|
"NOT NULL constraint must propagate, not be swallowed by isUniqueConstraintError"
|
|
);
|
|
fakeRaw.close();
|
|
fs.rmSync(realDir, { recursive: true, force: true });
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("isUniqueConstraintError: CHECK error propagates through claimServerToolExecution", () => {
|
|
const { dir, raw } = makeTempDb();
|
|
try {
|
|
const realDir = fs.mkdtempSync(path.join(os.tmpdir(), "fence-fake-"));
|
|
const fakeRaw = new BetterSqlite3(path.join(realDir, "test.db"));
|
|
fakeRaw.pragma("journal_mode = WAL");
|
|
fakeRaw.exec(FIXTURE_SCHEMA);
|
|
const fake = createBetterSqliteAdapter(fakeRaw);
|
|
|
|
const proxyAdapter = new Proxy(fake, {
|
|
get(target, prop) {
|
|
if (prop === "prepare") {
|
|
return (sql: string) => {
|
|
const stmt = (target.prepare as Function)(sql);
|
|
if (/INSERT INTO server_tool_executions/i.test(sql)) {
|
|
return {
|
|
...stmt,
|
|
run: (..._args: unknown[]) => {
|
|
const err = new Error("CHECK constraint failed") as Error & { code: string };
|
|
err.code = "SQLITE_CONSTRAINT_CHECK";
|
|
throw err;
|
|
},
|
|
};
|
|
}
|
|
return stmt;
|
|
};
|
|
}
|
|
return (target as Record<string, unknown>)[prop as string];
|
|
},
|
|
}) as SqliteAdapter;
|
|
|
|
assert.throws(
|
|
() => {
|
|
claimServerToolExecution(BASE_INPUT, proxyAdapter);
|
|
},
|
|
(err: unknown) => {
|
|
return (
|
|
err instanceof Error &&
|
|
err.message.includes("CHECK") &&
|
|
(err as { code?: string }).code === "SQLITE_CONSTRAINT_CHECK"
|
|
);
|
|
},
|
|
"CHECK constraint must propagate, not be swallowed by isUniqueConstraintError"
|
|
);
|
|
fakeRaw.close();
|
|
fs.rmSync(realDir, { recursive: true, force: true });
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
// ── Existing tests (cleaned up, no done callbacks) ──
|
|
|
|
test("finalize: only updates running rows, second finalize returns false", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim.kind, "claimed");
|
|
const firstFinalize = finalizeServerToolExecution(
|
|
{ ...BASE_FINALIZE, executionId: claim.executionId },
|
|
adapter
|
|
);
|
|
assert.equal(firstFinalize, true);
|
|
const secondFinalize = finalizeServerToolExecution(
|
|
{ ...BASE_FINALIZE, executionId: claim.executionId },
|
|
adapter
|
|
);
|
|
assert.equal(secondFinalize, false);
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("finalize: output sanitized — nested credentials stripped, valid JSON preserved", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim.kind, "claimed");
|
|
const sensitiveOutput = {
|
|
token: "sk-live-secret123",
|
|
nested: { bearer: "Bearer abcdefghijklmnop", deep: { key: "sk-test-abcdefghijklmnop" } },
|
|
data: "normal text",
|
|
};
|
|
finalizeServerToolExecution(
|
|
{ ...BASE_FINALIZE, executionId: claim.executionId, output: sensitiveOutput },
|
|
adapter
|
|
);
|
|
const row = raw
|
|
.prepare("SELECT output FROM server_tool_executions WHERE id = ?")
|
|
.get(claim.executionId) as { output: string | null };
|
|
assert.ok(row.output, "output should be stored");
|
|
assert.ok(
|
|
!row.output.includes("sk-live-secret123"),
|
|
"output must not contain raw sk- credential"
|
|
);
|
|
assert.ok(
|
|
!row.output.includes("Bearer abcdefghijklmnop"),
|
|
"output must not contain raw Bearer token"
|
|
);
|
|
const parsed = JSON.parse(row.output);
|
|
assert.ok(typeof parsed === "object", "sanitized output must be parseable JSON");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("finalize: error message sanitized — no raw stack in error_message", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim.kind, "claimed");
|
|
finalizeServerToolExecution(
|
|
{
|
|
executionId: claim.executionId,
|
|
status: "error",
|
|
output: null,
|
|
errorMessage: "Something failed\n at /internal/path.js:42\n at processTicksAndRejections",
|
|
durationMs: 50,
|
|
},
|
|
adapter
|
|
);
|
|
const row = raw
|
|
.prepare("SELECT error_message FROM server_tool_executions WHERE id = ?")
|
|
.get(claim.executionId) as { error_message: string | null };
|
|
assert.ok(row.error_message, "error_message should be stored");
|
|
assert.ok(!row.error_message.includes("at /internal/"), "error must not contain raw stack");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("finalize: output truncated to 32KB with valid JSON envelope", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim.kind, "claimed");
|
|
// Build a nested object that serializes to >32KB after sanitization
|
|
// (sanitizeUpstreamDetails truncates arrays to 32 elements, and strips 'key'-like names)
|
|
const bigObj: Record<string, string> = {};
|
|
for (let i = 0; i < 2000; i++) {
|
|
bigObj[`field_${i}_padding`] = "x".repeat(20);
|
|
}
|
|
finalizeServerToolExecution(
|
|
{ ...BASE_FINALIZE, executionId: claim.executionId, output: bigObj },
|
|
adapter
|
|
);
|
|
const row = raw
|
|
.prepare("SELECT output FROM server_tool_executions WHERE id = ?")
|
|
.get(claim.executionId) as { output: string | null };
|
|
assert.ok(row.output, "output must be stored");
|
|
assert.ok(row.output.length <= 32768, `output length ${row.output.length} must be <=32768`);
|
|
const parsed = JSON.parse(row.output);
|
|
assert.ok(parsed.truncated === true, "truncated envelope must have truncated:true");
|
|
assert.ok(typeof parsed.preview === "string", "truncated envelope must have preview string");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("finalize: raw tool arguments are never stored in the execution row", () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
try {
|
|
const claim = claimServerToolExecution(BASE_INPUT, adapter);
|
|
assert.equal(claim.kind, "claimed");
|
|
finalizeServerToolExecution(
|
|
{ ...BASE_FINALIZE, executionId: claim.executionId, output: { key: "value" } },
|
|
adapter
|
|
);
|
|
const row = raw
|
|
.prepare("SELECT output, input_digest FROM server_tool_executions WHERE id = ?")
|
|
.get(claim.executionId) as { output: string | null; input_digest: string };
|
|
assert.ok(row.output, "output should be stored");
|
|
assert.ok(row.input_digest, "input_digest must be digest only, not raw args");
|
|
} finally {
|
|
cleanup(raw, dir);
|
|
}
|
|
});
|
|
|
|
test("contention: two independent SQLite handles on same file — loser UNIQUE caught cleanly", () => {
|
|
const { adapter: adapter1, dir, raw: raw1 } = makeTempDb();
|
|
const { adapter: adapter2, raw: raw2 } = makeSecondAdapter(dir);
|
|
try {
|
|
const claim1 = claimServerToolExecution(BASE_INPUT, adapter1);
|
|
assert.equal(claim1.kind, "claimed");
|
|
const claim2 = claimServerToolExecution(BASE_INPUT, adapter2);
|
|
assert.ok(
|
|
claim2.kind === "replay" || claim2.kind === "in_progress",
|
|
`Expected replay or in_progress, got ${claim2.kind}`
|
|
);
|
|
} finally {
|
|
raw1.close();
|
|
raw2.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("fence: in-process promise joiner replays success result", async () => {
|
|
const { adapter, dir, raw } = makeTempDb();
|
|
let handlerCallCount = 0;
|
|
const handler = async () => {
|
|
handlerCallCount++;
|
|
return "handler-result";
|
|
};
|
|
|
|
const p1 = runWithServerToolFence(
|
|
{
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req:body",
|
|
toolCallId: "call-1",
|
|
toolName: "tool_a",
|
|
arguments: { q: "test" },
|
|
leaseDurationMs: 60_000,
|
|
execute: handler,
|
|
},
|
|
adapter
|
|
);
|
|
const p2 = runWithServerToolFence(
|
|
{
|
|
apiKeyId: "key-1",
|
|
requestIdentity: "key-1:req:body",
|
|
toolCallId: "call-1",
|
|
toolName: "tool_a",
|
|
arguments: { q: "test" },
|
|
leaseDurationMs: 60_000,
|
|
execute: handler,
|
|
},
|
|
adapter
|
|
);
|
|
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
assert.equal(handlerCallCount, 1, "handler should execute exactly once");
|
|
assert.equal(r1.kind, "executed");
|
|
assert.equal(r2.kind, "replayed");
|
|
if (r2.kind === "replayed") {
|
|
assert.equal(r2.status, "success");
|
|
assert.equal(r2.value, "handler-result");
|
|
}
|
|
cleanup(raw, dir);
|
|
});
|