Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ describe.skipIf(!RUN)("Cactus_Download (integration)", () => {

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "cactus-download-"));
cactusEngines.clear();
cactusConfigJson.clear();
cactusEngines().clear();
cactusConfigJson().clear();
});

afterEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ const Cactus_DownloadRemove = runFnFor(["model.download-remove"]);

describe("Cactus_DownloadRemove", () => {
afterEach(() => {
cactusEngines.clear();
cactusConfigJson.clear();
cactusEngines().clear();
cactusConfigJson().clear();
});

it("drops cached engine and config; emits finish", async () => {
cactusEngines.set("needle-26m", {} as any);
cactusConfigJson.set("needle-26m", { fake: true });
cactusEngines().set("needle-26m", {} as any);
cactusConfigJson().set("needle-26m", { fake: true });

let finished = false;
const controller = new AbortController();
Expand All @@ -40,7 +40,7 @@ describe("Cactus_DownloadRemove", () => {
}
);
expect(finished).toBe(true);
expect(cactusEngines.has("needle-26m")).toBe(false);
expect(cactusConfigJson.has("needle-26m")).toBe(false);
expect(cactusEngines().has("needle-26m")).toBe(false);
expect(cactusConfigJson().has("needle-26m")).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ describe.skipIf(!RUN)("Cactus_ToolCalling (integration)", () => {
});

afterAll(() => {
cactusEngines.clear();
cactusConfigJson.clear();
cactusEngines().clear();
cactusConfigJson().clear();
rmSync(dir, { recursive: true, force: true });
});

Expand Down
28 changes: 13 additions & 15 deletions providers/cactus/src/ai.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,33 @@
export * from "./ai/common/Cactus_Constants";
export * from "./ai/common/Cactus_ModelCatalog";
export * from "./ai/common/Cactus_ModelSchema";
// Mutable runtime state (e.g. cactusEngines, cactusEngineLoadsInFlight,
// cactusConfigJson, cactusSessions) is intentionally NOT re-exported here.
// The `./ai` and `./ai-runtime` entry points are bundled separately, so
// re-exporting from both creates two distinct module instances, and reads
// on one would not see writes from the other. Import runtime state from
// `@workglow/cactus/ai-runtime` instead.
// Mutable runtime state lives on a globalThis-keyed singleton (see
// `Cactus_RuntimeState`). The `./ai` and `./ai-runtime` entry points are
// bundled separately; if each held its own module-level Map, reads on one
// bundle would not see writes from the other. Routing through the singleton
// keeps state consistent across bundles in the same realm.
export * from "./ai/CactusProvider.browser";
export * from "./ai/CactusQueuedProvider.browser";
export * from "./ai/registerCactus.browser";

import { CactusQueuedProvider } from "./ai/CactusQueuedProvider.browser";
import { CACTUS_RUN_FN_SPECS } from "./ai/common/Cactus_Capabilities";
import { CACTUS_RUN_FNS } from "./ai/common/Cactus_JobRunFns.browser";
import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime.browser";
import { getCactusConfigJson, getCactusEngines } from "./ai/common/Cactus_Runtime.browser";

/**
* @internal Symbols exported only for use by `@workglow/test`. Not part of the stable public API.
*
* `cactusEngines` and `cactusConfigJson` are re-exported here so that tests can
* seed and inspect the runtime state used by the run-fns bundled into the `./ai`
* entry point. The `./ai` and `./ai-runtime` entry points are bundled separately
* and their runtime state copies are distinct module instances. Reading
* the runtime state via `_testOnly` (rather than `@workglow/cactus/ai-runtime`)
* guarantees the test observes the same Map that the run-fns mutate.
* Test consumers should call `cactusEngines()` / `cactusConfigJson()` to obtain
* the underlying Maps. Because runtime state is now backed by a
* `globalThis`-keyed singleton, both the `./ai` and `./ai-runtime` bundles
* observe the same Map identity — but accessing via functions also lets tests
* call `__resetRuntimeForTests()` between specs without stale captures.
*/
export const _testOnly = {
CactusQueuedProvider,
CACTUS_RUN_FN_SPECS,
CACTUS_RUN_FNS,
cactusEngines,
cactusConfigJson,
cactusEngines: getCactusEngines,
cactusConfigJson: getCactusConfigJson,
} as const;
28 changes: 13 additions & 15 deletions providers/cactus/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,33 @@
export * from "./ai/common/Cactus_Constants";
export * from "./ai/common/Cactus_ModelCatalog";
export * from "./ai/common/Cactus_ModelSchema";
// Mutable runtime state (e.g. cactusEngines, cactusEngineLoadsInFlight,
// cactusConfigJson, cactusSessions) is intentionally NOT re-exported here.
// The `./ai` and `./ai-runtime` entry points are bundled separately, so
// re-exporting from both creates two distinct module instances — and reads
// on one would not see writes from the other. Import runtime state from
// `@workglow/cactus/ai-runtime` instead.
// Mutable runtime state lives on a globalThis-keyed singleton (see
// `Cactus_RuntimeState`). The `./ai` and `./ai-runtime` entry points are
// bundled separately; if each held its own module-level Map, reads on one
// bundle would not see writes from the other. Routing through the singleton
// keeps state consistent across bundles in the same realm.
export * from "./ai/CactusProvider";
export * from "./ai/CactusQueuedProvider";
export * from "./ai/registerCactus";

import { CactusQueuedProvider } from "./ai/CactusQueuedProvider";
import { CACTUS_RUN_FN_SPECS } from "./ai/common/Cactus_Capabilities";
import { CACTUS_RUN_FNS } from "./ai/common/Cactus_JobRunFns";
import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime";
import { getCactusConfigJson, getCactusEngines } from "./ai/common/Cactus_Runtime";

/**
* @internal Symbols exported only for use by `@workglow/test`. Not part of the stable public API.
*
* `cactusEngines` and `cactusConfigJson` are re-exported here so that tests can
* seed and inspect the runtime state used by the run-fns bundled into the `./ai`
* entry point. The `./ai` and `./ai-runtime` entry points are bundled separately
* — their copies of `Cactus_Runtime.ts` are distinct module instances. Reading
* the runtime state via `_testOnly` (rather than `@workglow/cactus/ai-runtime`)
* guarantees the test observes the same Map that the run-fns mutate.
* Test consumers should call `cactusEngines()` / `cactusConfigJson()` to obtain
* the underlying Maps. Because runtime state is now backed by a
* `globalThis`-keyed singleton, both the `./ai` and `./ai-runtime` bundles
* observe the same Map identity — but accessing via functions also lets tests
* call `__resetRuntimeForTests()` between specs without stale captures.
*/
export const _testOnly = {
CactusQueuedProvider,
CACTUS_RUN_FN_SPECS,
CACTUS_RUN_FNS,
cactusEngines,
cactusConfigJson,
cactusEngines: getCactusEngines,
cactusConfigJson: getCactusConfigJson,
} as const;
28 changes: 22 additions & 6 deletions providers/cactus/src/ai/common/Cactus_Download.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
ModelDownloadTaskRunInput,
ModelDownloadTaskRunOutput,
} from "@workglow/ai";
import { getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import { CactusIntegrityError } from "./Cactus_Integrity";
import { assetSpecsOf, getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import type { CactusModelConfig } from "./Cactus_ModelSchema";
import { fetchAssetBytes, markModelCached } from "./Cactus_Runtime.browser";

Expand All @@ -23,14 +24,29 @@ export const Cactus_Download: AiProviderRunFn<
const entry = getCactusCatalogEntry(model_id);
if (!entry) throw new Error(`Unknown Cactus model_id: ${model_id}`);

const assets = [entry.assets.weights, entry.assets.vocab, entry.assets.config];
for (let i = 0; i < assets.length; i++) {
const specs = assetSpecsOf(entry);
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
emit({
type: "phase",
message: `Downloading ${assets[i]}`,
progress: Math.round(((i + 0.5) / assets.length) * 99),
message: `Downloading ${spec.filename}`,
progress: Math.round(((i + 0.5) / specs.length) * 99),
});
await fetchAssetBytes(model, assets[i]);
try {
await fetchAssetBytes(model, spec);
} catch (err) {
// Surface whatever the integrity layer phrased — it knows whether the
// mismatch was a SHA-256 digest or a byte-length pre-check, and the
// error message is already shaped correctly for both.
// StreamPhase.progress is required (number | undefined); pass undefined
// on the error path because there is no meaningful percentage to report.
emit({
type: "phase",
message: err instanceof CactusIntegrityError ? err.message : String(err),
progress: undefined,
});
throw err;
}
}
markModelCached(model_id);
emit({ type: "finish", data: { model: input.model! } });
Expand Down
28 changes: 22 additions & 6 deletions providers/cactus/src/ai/common/Cactus_Download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
ModelDownloadTaskRunInput,
ModelDownloadTaskRunOutput,
} from "@workglow/ai";
import { getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import { CactusIntegrityError } from "./Cactus_Integrity";
import { assetSpecsOf, getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import type { CactusModelConfig } from "./Cactus_ModelSchema";
import { fetchAssetBytes, markModelCached } from "./Cactus_Runtime";

Expand All @@ -23,14 +24,29 @@ export const Cactus_Download: AiProviderRunFn<
const entry = getCactusCatalogEntry(model_id);
if (!entry) throw new Error(`Unknown Cactus model_id: ${model_id}`);

const assets = [entry.assets.weights, entry.assets.vocab, entry.assets.config];
for (let i = 0; i < assets.length; i++) {
const specs = assetSpecsOf(entry);
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
emit({
type: "phase",
message: `Downloading ${assets[i]}`,
progress: Math.round(((i + 0.5) / assets.length) * 99),
message: `Downloading ${spec.filename}`,
progress: Math.round(((i + 0.5) / specs.length) * 99),
});
await fetchAssetBytes(model, assets[i]);
try {
await fetchAssetBytes(model, spec);
} catch (err) {
// Surface whatever the integrity layer phrased — it knows whether the
// mismatch was a SHA-256 digest or a byte-length pre-check, and the
// error message is already shaped correctly for both.
// StreamPhase.progress is required (number | undefined); pass undefined
// on the error path because there is no meaningful percentage to report.
emit({
type: "phase",
message: err instanceof CactusIntegrityError ? err.message : String(err),
progress: undefined,
});
throw err;
}
}
markModelCached(model_id);
emit({ type: "finish", data: { model: input.model! } });
Expand Down
124 changes: 124 additions & 0 deletions providers/cactus/src/ai/common/Cactus_Integrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* SHA-256 integrity verification for Cactus model assets.
*
* The trust boundary for locally-executed model weights is anchored at the
* catalog: every byte loaded from disk, Cache Storage, or the network must
* hash to the catalog-pinned digest. Anything else is treated as adversarial
* and refused.
*/

/** Sentinel value used in the catalog while real hashes are not yet populated. */
export const CACTUS_HASH_PLACEHOLDER = "TODO_FILL_AT_RELEASE";

/**
* Raised whenever a Cactus asset fails an integrity check. The `expected`
* and `actual` fields are deliberately label-agnostic strings so the same
* error type covers both SHA-256 mismatches ("abc123...") and byte-length
* mismatches ("22000000 bytes"). The constructor message embeds them
* verbatim, so callers should phrase each side with whatever unit makes
* sense at the call site.
*/
export class CactusIntegrityError extends Error {
readonly url: string;
readonly filename: string;
readonly expected: string;
readonly actual: string;
constructor(opts: { url: string; filename: string; expected: string; actual: string }) {
super(
`Integrity check failed for ${opts.filename} from ${opts.url}: ` +
`expected ${opts.expected}, got ${opts.actual}`
);
this.name = "CactusIntegrityError";
this.url = opts.url;
this.filename = opts.filename;
this.expected = opts.expected;
this.actual = opts.actual;
}
}

export async function sha256Hex(bytes: Uint8Array | ArrayBuffer): Promise<string> {
// Copy into a fresh ArrayBuffer so we hand crypto.subtle.digest a concrete
// `BufferSource` whose backing buffer is `ArrayBuffer` (not `ArrayBufferLike`).
// The recent lib.dom tightening on Uint8Array's default generic argument made
// the previous `new Uint8Array(bytes)` path no longer assignable to digest's
// parameter type.
const src =
bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes as ArrayBuffer);
const buf = new ArrayBuffer(src.byteLength);
new Uint8Array(buf).set(src);
const digest = await globalThis.crypto.subtle.digest("SHA-256", buf);
const view = new Uint8Array(digest);
let s = "";
for (let i = 0; i < view.length; i++) {
s += view[i].toString(16).padStart(2, "0");
}
return s;
}

/**
* Returns `true` if `expected` is the well-known placeholder that means
* "maintainer has not populated a real hash yet." In that case callers SHOULD
* skip verification but MUST log a clear warning — this is intended for
* pre-release dev only and must never reach a tagged release.
*/
export function isHashPlaceholder(expected: string): boolean {
return expected === CACTUS_HASH_PLACEHOLDER;
}

/**
* Hashes `bytes` and throws `CactusIntegrityError` if it does not match
* `expected`. Throws a plain `Error` if `expected` is malformed (not 64 hex
* chars), since that is a catalog-author bug, not a content bug.
*
* When the hashes mismatch, both `expected` and `actual` are lowercase hex
* SHA-256 strings; the resulting error message reads
* `expected <hex>, got <hex>`.
*
* If `expected` is the `TODO_FILL_AT_RELEASE` placeholder, verification is
* skipped and a one-time warning is logged. This keeps developers unblocked
* before the real hashes land while making the gap impossible to miss.
*/
export async function verifySha256(
bytes: Uint8Array | ArrayBuffer,
expected: string,
ctx: { url: string; filename: string }
): Promise<void> {
if (isHashPlaceholder(expected)) {
warnPlaceholderOnce(ctx.filename);
return;
}
if (typeof expected !== "string" || expected.length !== 64) {
throw new Error(
`Invalid catalog SHA-256 for ${ctx.filename}: must be 64 hex chars (got length ${
typeof expected === "string" ? expected.length : typeof expected
})`
);
}
const expectedLc = expected.toLowerCase();
if (!/^[0-9a-f]{64}$/.test(expectedLc)) {
throw new Error(
`Invalid catalog SHA-256 for ${ctx.filename}: contains non-hex characters`
);
}
const actual = await sha256Hex(bytes);
if (actual !== expectedLc) {
throw new CactusIntegrityError({ ...ctx, expected: expectedLc, actual });
}
}

const _warnedFiles = new Set<string>();
function warnPlaceholderOnce(filename: string): void {
if (_warnedFiles.has(filename)) return;
_warnedFiles.add(filename);
// eslint-disable-next-line no-console
console.warn(
`[@workglow/cactus] SHA-256 catalog entry for "${filename}" is a placeholder; ` +
`integrity verification is DISABLED. This must be populated before release.`
);
}
4 changes: 2 additions & 2 deletions providers/cactus/src/ai/common/Cactus_JobRunFns.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import { Cactus_ModelSearch } from "./Cactus_ModelSearch";
import { Cactus_ToolCalling } from "./Cactus_ToolCalling.browser";

export {
cactusConfigJson,
cactusEngines,
deleteCactusSession,
disposeCactusResources,
getCactusConfigJson,
getCactusEngines,
getOrLoadEngine,
loadSdk,
removeCachedAssets,
Expand Down
4 changes: 2 additions & 2 deletions providers/cactus/src/ai/common/Cactus_JobRunFns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import { Cactus_ModelSearch } from "./Cactus_ModelSearch";
import { Cactus_ToolCalling } from "./Cactus_ToolCalling";

export {
cactusConfigJson,
cactusEngines,
deleteCactusSession,
disposeCactusResources,
getCactusConfigJson,
getCactusEngines,
getOrLoadEngine,
loadSdk,
removeCachedAssets,
Expand Down
Loading
Loading