UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

209 lines • 9.04 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 { FlagView } from "../../../spec/flag_view.js"; import { IOResult, materialize } from "../../../../io/types.js"; import { serdeMessage } from "./serde.js"; import { compactJson, firstText, notionTransport, rustDebug, usageError } from "./util.js"; const ENC = new TextEncoder(); const DEC = new TextDecoder(); // Every byte below is probed against ntn 0.21.9. The exit codes are not one // family: a body that arrived malformed is 1, while a line the CLI refuses to // interpret at all is 5, and neither is argparse's 2. const BAD_DATA = 'error: Invalid JSON from --data\n'; const BAD_STDIN = 'error: Invalid JSON from stdin\n'; const EMPTY_DATA = 'error: --data requires a valid JSON value.\n' + ' hint: Pass a JSON string such as `--data \'{"foo":"bar"}\'`, a file ' + 'such as `--data @body.json`, or stdin with `--data @-`.\n'; const INLINE_LEAD = 'error: Failed to parse inline request input: '; const INLINE_HINT = ' hint: Use `Header:Value`, `name==value`, `path=value`, or `path:=json`.\n'; const CONFLICT_LEAD = 'error: Request body can come from only one source, but got: '; const CONFLICT_HINT = ' hint: Use only one of: stdin JSON, `--data`, or `path=value` / `path:=json` inputs.\n'; const STDIN_SOURCE = 'stdin JSON'; const DATA_SOURCE = '--data'; const INLINE_SOURCE = 'inline body inputs'; const BAD_BODY_EXIT = 1; const REFUSAL_EXIT = 5; const EMPTY_METHOD = 'error: Invalid HTTP method ``.\n hint: Use a standard method such as ' + 'GET, POST, PATCH, PUT, or DELETE.\n'; // DELETE is here because `DELETE /v1/blocks/{id}` is the only delete verb the // public API has, so without it the one way to remove anything is unreachable // from this CLI. It takes no body, which is why it is reached through `-X` // rather than by a body source inferring it. const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE']); // One inline input the CLI could not interpret, carrying the clause upstream // puts after its fixed lead. class InlineRefusal extends Error { } // Walk one level into a body being built, creating the container the next // key implies (an array for an index or an append, an object otherwise). function descend(cursor, key, blank) { if (Array.isArray(cursor)) { const at = key === '' ? cursor.length : Number(key); cursor[at] ??= blank; return cursor[at]; } cursor[key] ??= blank; return cursor[key]; } function place(cursor, key, value) { if (Array.isArray(cursor)) { if (key === '') cursor.push(value); else cursor[Number(key)] = value; return; } cursor[key] = value; } // Set a bracket path (`a[b][0][c]`) inside a request body. function assign(body, path, value) { const at = path.indexOf('['); const head = at === -1 ? path : path.slice(0, at); const keys = [head]; if (at !== -1) { for (const chunk of path.slice(at + 1).split('[')) keys.push(chunk.replace(/]$/, '')); } let cursor = body; for (let index = 0; index < keys.length - 1; index += 1) { const step = keys[index + 1] ?? ''; const blank = step === '' || /^\d+$/.test(step) ? [] : {}; cursor = descend(cursor, keys[index] ?? '', blank); } place(cursor, keys[keys.length - 1] ?? '', value); } // Precedence is the upstream CLI's, and it is order-sensitive: `path:=json` // beats `name==value` beats `Header:Value` beats `path=value`, so a value // containing one separator cannot be reclassified by another later in it. function classify(token, body, params, headers) { const typed = token.indexOf(':='); if (typed !== -1) { const name = token.slice(0, typed); const raw = token.slice(typed + 2); // serde decides, not JSON.parse: the two disagree about what is valid // and never agree on the wording, and this message is compared byte for // byte against the real binary. const message = serdeMessage(raw); if (message !== null) { throw new InlineRefusal(`invalid JSON value in ${rustDebug(token)}: ${message}`); } assign(body, name, JSON.parse(raw)); return; } const queried = token.indexOf('=='); if (queried !== -1) { params[token.slice(0, queried)] = token.slice(queried + 2); return; } const colon = token.indexOf(':'); const equals = token.indexOf('='); if (colon !== -1 && (equals === -1 || colon < equals)) { headers[token.slice(0, colon)] = token.slice(colon + 1); return; } if (equals !== -1) { assign(body, token.slice(0, equals), token.slice(equals + 1)); return; } throw new InlineRefusal(`unexpected input: ${rustDebug(token)}`); } // One of upstream's own refusals, rendered. function refusal(stderr, code) { return [null, new IOResult({ stderr: ENC.encode(stderr), exitCode: code })]; } export async function api(accessor, _paths, texts, ctx) { const fl = new FlagView(ctx.flags); const inline = {}; const params = {}; const headers = {}; let path; let method; let body; let named; try { path = firstText(texts, 'api path'); // Upstream validates the three body sources in this order and only then // complains that more than one was given, so a malformed pipe outranks a // malformed --data, and both outrank the conflict. Probed against ntn // 0.21.9; the order is observable and worth keeping. const piped = ctx.stdin !== null ? DEC.decode(await materialize(ctx.stdin)) : ''; const hasStdin = piped.trim() !== ''; let stdinBody; if (hasStdin) { if (serdeMessage(piped) !== null) return refusal(BAD_STDIN, BAD_BODY_EXIT); stdinBody = JSON.parse(piped); } const data = fl.asStr('data'); const hasData = data !== undefined; let dataBody; if (data !== undefined) { if (data.trim() === '') return refusal(EMPTY_DATA, REFUSAL_EXIT); if (serdeMessage(data) !== null) return refusal(BAD_DATA, BAD_BODY_EXIT); dataBody = JSON.parse(data); } try { for (const token of texts.slice(1)) classify(token, inline, params, headers); } catch (err) { if (!(err instanceof InlineRefusal)) throw err; return refusal(`${INLINE_LEAD}${err.message}\n${INLINE_HINT}`, REFUSAL_EXIT); } const hasInline = Object.keys(inline).length > 0; named = [ ...(hasStdin ? [STDIN_SOURCE] : []), ...(hasData ? [DATA_SOURCE] : []), ...(hasInline ? [INLINE_SOURCE] : []), ]; if (named.length > 1) { return refusal(`${CONFLICT_LEAD}${named.join(', ')}.\n${CONFLICT_HINT}`, REFUSAL_EXIT); } // A body source makes the call a POST even when what it carries is empty: // `--data {}` posts, and there is no object check anywhere, so a list or // a scalar goes out exactly as it was typed. if (hasStdin) body = stdinBody; else if (hasData) body = dataBody; else if (hasInline) body = inline; method = fl.asStr('method', named.length > 0 ? 'POST' : 'GET').toUpperCase(); if (method === '') return refusal(EMPTY_METHOD, REFUSAL_EXIT); if (!METHODS.has(method)) throw new Error(`unsupported method: ${method}`); } catch (err) { return usageError(err); } const rooted = path.startsWith('/') ? path : `/${path}`; const route = rooted.startsWith('/v1/') ? rooted.slice(3) : rooted; const call = { method: method, path: route }; // `name==value` is a query parameter whatever the method is, so it rides // alongside the body rather than being dropped once the call stops being // a GET. if (Object.keys(params).length > 0) call.query = params; if (method !== 'GET') call.body = body; if (Object.keys(headers).length > 0) call.headers = headers; const result = await notionTransport(accessor, ctx.flags).request(call); return [compactJson(result), new IOResult()]; } //# sourceMappingURL=api.js.map