@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
229 lines • 9.62 kB
JavaScript
// ========= 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