UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

229 lines • 9.62 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { z } from 'zod'; import { normalizedKey, normalizeFields } from "../utils/normalize.js"; import { isRecord } from "../utils/records.js"; // Re-exported so a config schema in the browser or node package is built // with the zod core resolves, not with a second copy of its own. The // helpers below are typed against this instance, and two installs make // every ZodObject here structurally unrelated to the one over there -- // it still type-checks, but it takes minutes per config file. export { z }; export const REDACTED_SECRET = '<REDACTED>'; const SECRET_META_KEY = 'mirageSecret'; export function secretStr() { return secretSchema(z.string()); } export function secretSchema(schema) { return schema.meta({ [SECRET_META_KEY]: true }); } /** * The python spellings a config schema takes, registered beside the schema * (`z.object({...}).register(FIELD_NORMALIZERS, { rename: {...} })`) the way * a pydantic field carries its alias, so every caller of * {@link parseConfigWithSchema} renames the same keys. */ export const FIELD_NORMALIZERS = z.registry(); /** * The one loader a mount config comes through: rename the python-side * spellings, then validate. * * Every registry entry calls this with its backend's schema, so a config * that reaches a VFS has been checked the way pydantic checks the python * twin on construction. It used to be `normalizeFields` alone in 56 of 60 * normalizers, which let `bucket: 123`, `timeout: "abc"` and a Google * config naming no credential at all reach their clients unrefused, and * left every requiredness rule in these schemas unreachable from the mount * path -- `GitHubConfigSchema` required `owner` and nothing ever asked it. * The renames come from the schema's {@link FIELD_NORMALIZERS} entry. * * Unknown keys are refused, as every python config refuses them with * `extra="forbid"`: they used to be stripped, so a typo'd `team_idz` built * a mount that exposed every team, with no error on either side. The check * runs on the input before the renames, so a snake_case spelling of a * declared field is known and an unknown key is named as the block wrote * it. A callable a config carries (a token provider, a refresh hook) must * be declared in the schema through `secretSchema(z.custom(...))` or it is * an unknown key too -- the declaration is also what keeps it out of * snapshot state. */ export function parseConfigWithSchema(schema, input) { const normalizer = FIELD_NORMALIZERS.get(schema) ?? {}; if (refusesExtraKeys(schema)) refuseUnknownKeys(input, Object.keys(schema.shape), normalizer); return schema.parse(normalizeFields(input, normalizer)); } /** * Refuse the keys of a config block that no field takes. * * Each key is looked up under the name the normalizer writes it to, and an * unknown one is reported in the block's own spelling, one * `unrecognized_keys` issue per key: the summary then reads * `<vfs>: team_idz: unrecognized_keys` where python's reads * `<vfs>: team_idz: extra_forbidden`, the same field named the same way. * Exported for the backends that take their options without a schema * (`ram`, `disk`, `redis`, `opfs`), which python builds from constructor * keywords and refuses the same way. */ export function refuseUnknownKeys(input, fields, normalizer = {}) { const known = new Set(fields); const drop = new Set(normalizer.drop ?? []); const unknown = Object.keys(input).filter((key) => !drop.has(key) && !known.has(normalizedKey(key, normalizer))); if (unknown.length === 0) return; throw new z.ZodError(unknown.map((key) => ({ code: 'unrecognized_keys', keys: [key], path: [], message: `Unrecognized key: ${JSON.stringify(key)}`, }))); } /** * Refuse a config block that names one field under two spellings. * * `normalizeFields` writes `key_prefix` and `keyPrefix` to the same key, * so without this the later one would win by object order. Python's * models take only the snake_case spelling and refuse the camelCase one * as an extra field, so each clash is reported the way * `refuseUnknownKeys` reports a key: an `unrecognized_keys` issue naming * the camelCase spelling, whose summary then reads as python's * `keyPrefix: extra_forbidden` does. */ export function refuseRepeatedFields(input) { const seen = new Map(); const issues = []; for (const key of Object.keys(input)) { const field = normalizedKey(key); const first = seen.get(field); if (first === undefined) { seen.set(field, key); continue; } issues.push({ code: 'unrecognized_keys', keys: [field], path: [], message: `${JSON.stringify(first)} and ${JSON.stringify(key)} name the same field`, }); } if (issues.length > 0) throw new z.ZodError(issues); } /** A string field trimmed, refused when nothing is left. */ export function normalizeNonEmpty(value, field) { const normalized = value.trim(); if (normalized === '') { throw new Error(`${field} cannot be empty`); } return normalized; } // A schema that declares its own policy for extra keys keeps it: a loose // one passes them through the way pydantic's `extra="allow"` does. A strict // one refuses them in parse as well, but after the renames, so zod would // name the camelCase spelling; it is checked up front like the default. function refusesExtraKeys(schema) { const catchall = schema.def.catchall; return catchall === undefined || catchall instanceof z.ZodNever; } export function redactConfigWithSchema(schema, config) { return redactValueWithSchema(schema, schema.parse(config)); } export function hasRedactedSecret(value) { if (value === REDACTED_SECRET) return true; if (Array.isArray(value)) return value.some((v) => hasRedactedSecret(v)); if (value !== null && typeof value === 'object') { return Object.values(value).some((v) => hasRedactedSecret(v)); } return false; } /** * Whether restoring this mount needs a live VFS handed in. * * A redaction marker says the saved config is missing a credential. The * explicit `needs_override` says the mount cannot be rebuilt from its * state at all — which in TypeScript is true of every config-backed * backend, because `buildMountArgs` substitutes a `RAMVFS` for any * mount it was not given (`snapshot/state.ts`). Python instead * reconstructs the class from `vfs_state["type"]` via its registry * (`_vfs_class_for`), so the field is inert there and only four of * its VFS classes bother to write it; TypeScript has to read it or a live * database mount comes back as an empty directory. * * The lasting fix is to give `buildMountArgs` a VFS factory, which * core cannot import today (`buildVfs` lives in node/browser). */ export function vfsStateRequiresOverride(state) { if (!isRecord(state)) return false; if (state.needs_override === true) return true; return hasRedactedSecret(state.config); } function redactValueWithSchema(schema, value) { if (value === undefined || value === null) return value; if (isSecretSchema(schema)) return REDACTED_SECRET; const unwrapped = unwrapSchema(schema); if (unwrapped instanceof z.ZodObject && isRecord(value)) { return redactObjectWithSchema(unwrapped, value); } if (unwrapped instanceof z.ZodArray && Array.isArray(value)) { return value.map((item) => redactValueWithSchema(unwrapped.element, item)); } return value; } function redactObjectWithSchema(schema, value) { const out = { ...value }; const shape = schema.shape; for (const [key, childSchema] of Object.entries(shape)) { if (!(key in out)) continue; out[key] = redactValueWithSchema(childSchema, out[key]); } return out; } // A record of secrets is one secret field, as Python's dict[str, SecretStr] is. function isSecretSchema(schema) { const unwrapped = unwrapSchema(schema); if (schemaMeta(unwrapped)?.[SECRET_META_KEY] === true) return true; return unwrapped instanceof z.ZodRecord && isSecretSchema(unwrapped.valueType); } function unwrapSchema(schema) { let current = schema; for (;;) { if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) { current = current.unwrap(); continue; } const def = schemaDef(current); if (def.innerType !== undefined) { current = def.innerType; continue; } return current; } } function schemaMeta(schema) { const maybeMeta = schema.meta; return maybeMeta?.(); } function schemaDef(schema) { const candidate = schema; return candidate.def ?? candidate._zod?.def ?? {}; } //# sourceMappingURL=secrets.js.map