Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,19 @@ check:*` script, so a violation fails CI rather than waiting for review.
`@corbits/error-sink` — never a bare `catch {}`, never a toast alone. It
attaches operation/tenant/room/agent context and a `refId` a person can
quote to support, and redacts secrets before anything reaches a log
sink.
sink. Unlike every other rule in this section, this one does not yet
hold retroactively: `check:report-error` fails a catch clause that
neither calls `reportError` nor rethrows only when the catch is new or
the current change's diff touches its line; a pre-existing catch is
instead recorded in `scripts/checks/report-error-baseline.txt`, a debt
ledger — not an allowlist — of 280 violations as of this check landing,
each one a real bug still to fix. Regenerate it with `bun run
scripts/checks/report-error.ts --write-baseline` after fixing (or
newly opting out) entries; a stale entry with no matching violation
fails the check, so the ledger can only shrink. A violation already
tracked on its own ticket opts out instead with a `report-error-ignore:
<reason>` comment on the catch or in its body — that's for a violation
actively being fixed, never a way to clear a baseline entry quietly.
- A package's `browser-safe` subpath (e.g. `@corbits/routines/client`) may
never import a server-only dependency (`postgres`, `drizzle-orm`,
`hono`, any `@intx/*`) — `check:browser-safe-subpaths` walks the real
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
"setup:memory": "bun run scripts/setup-memory.ts",
"seed": "bun packages/cli/src/index.ts seed",
"reset": "bun packages/cli/src/index.ts reset",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness",
"check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:report-error",
"check:deletion": "bun run scripts/checks/deletion.ts",
"check:report-error": "bun run scripts/checks/report-error.ts",
"check:killdates": "bun run scripts/checks/killdates.ts",
"check:packages": "bun run scripts/checks/packages.ts",
"check:licenses": "bun run scripts/checks/licenses.ts",
Expand Down
56 changes: 54 additions & 2 deletions packages/connections/src/connected-hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";
import {
fireConnectedHook,
fireInferenceCredentialSeedableHook,
type InferenceCredentialSeedableInfo,
type ServiceConnectedInfo,
} from "./connected-hook";

function seedableInfo(): InferenceCredentialSeedableInfo {
Expand All @@ -15,6 +18,46 @@ function seedableInfo(): InferenceCredentialSeedableInfo {
};
}

function connectedInfo(): ServiceConnectedInfo {
return {
tenantId: "tenant_1",
principalId: "principal_1",
connectorId: "github",
displayName: "GitHub",
};
}

describe("fireConnectedHook", () => {
test("does nothing when no hook is wired", async () => {
await expect(
fireConnectedHook(undefined, () => {}, connectedInfo()),
).resolves.toBeUndefined();
});

test("logs and reports a hook failure rather than breaking the connect", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const logged: string[] = [];
await expect(
fireConnectedHook(
() => {
throw new Error("card settle unavailable");
},
(line) => logged.push(line),
connectedInfo(),
),
).resolves.toBeUndefined();
expect(logged).toHaveLength(1);
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "fire_connected_hook",
tenantId: "tenant_1",
extra: { connectorId: "github" },
});
report.mockRestore();
});
});

describe("fireInferenceCredentialSeedableHook", () => {
test("does nothing when no hook is wired", async () => {
await expect(
Expand All @@ -34,7 +77,8 @@ describe("fireInferenceCredentialSeedableHook", () => {
expect(calls).toEqual([seedableInfo()]);
});

test("logs and swallows a hook failure rather than breaking the connect", async () => {
test("logs and reports a hook failure rather than breaking the connect", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const logged: string[] = [];
await expect(
fireInferenceCredentialSeedableHook(
Expand All @@ -48,5 +92,13 @@ describe("fireInferenceCredentialSeedableHook", () => {
expect(logged).toHaveLength(1);
expect(logged[0]).toContain("tenant_1");
expect(logged[0]).toContain("drain unavailable");
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "fire_inference_credential_seedable_hook",
tenantId: "tenant_1",
extra: { provider: "ollama" },
});
report.mockRestore();
});
});
11 changes: 11 additions & 0 deletions packages/connections/src/connected-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// without knowing which door the connection came through. Best-effort
// by contract: a hook failure is logged and never breaks the connect
// itself — the credential is already stored when this fires.
import { reportError } from "@corbits/error-sink";

export type ServiceConnectedInfo = {
readonly tenantId: string;
Expand All @@ -30,6 +31,11 @@ export async function fireConnectedHook(
log(
`onConnected hook failed for ${info.connectorId} on tenant ${info.tenantId}: ${message}`,
);
reportError(cause, {
operation: "fire_connected_hook",
tenantId: info.tenantId,
extra: { connectorId: info.connectorId },
});
}
}

Expand Down Expand Up @@ -77,5 +83,10 @@ export async function fireInferenceCredentialSeedableHook(
log(
`onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`,
);
reportError(cause, {
operation: "fire_inference_credential_seedable_hook",
tenantId: info.tenantId,
extra: { provider: info.provider },
});
}
}
10 changes: 9 additions & 1 deletion packages/connections/src/github-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// surface GitHub's own `error`/`error_description` shape (distinct from
// a non-2xx status — GitHub's token endpoint answers 200 with an `error`
// field on a rejected code).
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";
import {
exchangeCodeForGithubToken,
type ExchangeFetch,
Expand Down Expand Up @@ -101,6 +102,7 @@ describe("exchangeCodeForGithubToken", () => {
});

test("a transport failure is reported honestly", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const fetchImpl: ExchangeFetch = async () => {
throw new Error("getaddrinfo ENOTFOUND");
};
Expand All @@ -118,6 +120,12 @@ describe("exchangeCodeForGithubToken", () => {
expect(result.message).toContain("Could not reach GitHub");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
}
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_github_token",
});
report.mockRestore();
});

// CL-7235: a GitHub token endpoint that never answers used to leave
Expand Down
2 changes: 2 additions & 0 deletions packages/connections/src/github-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// still names `credentialPlugin: "http"`).

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

import {
postExchangeRequest,
Expand Down Expand Up @@ -67,6 +68,7 @@ export async function exchangeCodeForGithubToken(
}),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_github_token" });
return {
ok: false,
message:
Expand Down
25 changes: 24 additions & 1 deletion packages/connections/src/gmail-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// entirely against a stubbed fetch — no Google credentials involved.
// Live-key verification against a real Google OAuth app is a deploy
// concern (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET`), not a test one.
import { expect, test } from "bun:test";
import { expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";

import {
exchangeCodeForGoogleToken,
Expand Down Expand Up @@ -117,3 +118,25 @@ test("wires a bounded AbortSignal into the exchange fetch so a stalled provider
expect(capturedSignal).toBeInstanceOf(AbortSignal);
expect(capturedSignal?.aborted).toBe(false);
});

test("a transport failure is reported and never crashes", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const result = await exchangeCodeForGoogleToken({
code: "auth-code-1",
redirectUri: "https://bench.example.com/callback",
clientId: "client-1",
clientSecret: "secret-1",
fetchImpl: async () => {
throw new Error("getaddrinfo ENOTFOUND");
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected failure");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_google_token",
});
report.mockRestore();
});
2 changes: 2 additions & 0 deletions packages/connections/src/gmail-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// authorize URL) for the credential row to keep alongside the secret.

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

import {
postExchangeRequest,
Expand Down Expand Up @@ -85,6 +86,7 @@ export async function exchangeCodeForGoogleToken(
body: params.toString(),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_google_token" });
const message = cause instanceof Error ? cause.message : String(cause);
return { ok: false, message: `Google token exchange failed: ${message}` };
}
Expand Down
11 changes: 10 additions & 1 deletion packages/connections/src/huggingface-connect.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Unit tests for the Hugging Face connector's code-for-token exchange,
// driven entirely against a stubbed fetch -- no Hugging Face credentials
// involved.
import { expect, test } from "bun:test";
import { expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";

import {
exchangeCodeForToken,
Expand Down Expand Up @@ -40,6 +41,7 @@ test("exchanges the code and verifier for an access token and expiry", async ()
});

test("a transport failure is reported honestly, never as a token", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const fetchImpl: ExchangeFetch = async () => {
throw new Error("getaddrinfo ENOTFOUND");
};
Expand All @@ -55,7 +57,14 @@ test("a transport failure is reported honestly, never as a token", async () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain("Could not reach Hugging Face");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
}
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_huggingface_token",
});
report.mockRestore();
});

// CL-7235: a Hugging Face token endpoint that never answers used to
Expand Down
2 changes: 2 additions & 0 deletions packages/connections/src/huggingface-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// later expiry sweep reads.

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

import {
postExchangeRequest,
Expand Down Expand Up @@ -88,6 +89,7 @@ export async function exchangeCodeForToken(
body: body.toString(),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_huggingface_token" });
return {
ok: false,
message:
Expand Down
Loading
Loading