Fork Horse repository, listed by its owner. Publisher snapshot.
packages/forkhorse-cli/src/safety.ts
Snapshot at 34e3175
1export class CliError extends Error {}
2
3export function record(value: unknown): value is Record<string, unknown> {
4 return value !== null && typeof value === "object" && !Array.isArray(value);
5}
6
7export function slug(value: string | undefined, max = 48): string {
8 if (!value || value.length > max || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value))
9 throw new CliError("Expected a lowercase ID with single internal hyphens.");
10 return value;
11}
12
13export function endpoint(value: unknown, originOnly = false): URL {
14 if (typeof value !== "string") throw new CliError("A base URL is required.");
15 let url: URL;
16 try {
17 url = new URL(value);
18 } catch {
19 throw new CliError("Invalid endpoint URL.");
20 }
21 const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
22 if (
23 (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
24 url.username ||
25 url.password ||
26 url.search ||
27 url.hash ||
28 (originOnly && url.pathname !== "/")
29 )
30 throw new CliError(
31 "Use an HTTPS origin (loopback HTTP is for fixtures only), without credentials, query or fragment.",
32 );
33 return url;
34}
35
36// Redact credential fields at every depth, plus credentials embedded in ordinary text/keys.
37// Error bodies are never rendered at all. Core's agent API tokens have the api_ prefix.
38export function redact(value: unknown, owner: string): unknown {
39 const clean = (text: string) =>
40 (owner ? text.split(owner).join("[REDACTED]") : text)
41 .replace(/api_[a-zA-Z0-9_-]+/g, "[REDACTED]")
42 .replace(/Bearer\s+[^\s"\\]+/gi, "Bearer [REDACTED]")
43 .replace(/\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, "[REDACTED]");
44 if (typeof value === "string") return clean(value);
45 if (Array.isArray(value)) return value.map((item) => redact(item, owner));
46 if (record(value))
47 return Object.fromEntries(
48 Object.entries(value).map(([key, item]) => [
49 clean(key),
50 /token|secret|password|credential|authorization|cookie|hash/i.test(key)
51 ? "[REDACTED]"
52 : redact(item, owner),
53 ]),
54 );
55 return value;
56}
57
58export class Api {
59 private base: URL;
60 private owner: string;
61 private signal: AbortSignal;
62
63 constructor(base: URL, owner: string, signal: AbortSignal) {
64 this.base = base;
65 this.owner = owner;
66 this.signal = signal;
67 }
68
69 async request(path: string, data?: unknown): Promise<Record<string, unknown>> {
70 const encoded = data === undefined ? undefined : JSON.stringify(data);
71 if (encoded && Buffer.byteLength(encoded) > 16384)
72 throw new CliError("Request exceeds core's 16 KiB body limit.");
73 try {
74 const response = await fetch(new URL(path, this.base), {
75 method: encoded === undefined ? "GET" : "POST",
76 headers: { authorization: `Bearer ${this.owner}`, "content-type": "application/json" },
77 body: encoded,
78 redirect: "manual",
79 signal: AbortSignal.any([this.signal, AbortSignal.timeout(25000)]),
80 });
81 if (!response.ok) {
82 await response.body?.cancel();
83 throw new CliError(
84 `Core returned HTTP ${response.status}; inspect state before retrying writes.`,
85 );
86 }
87 const reader = response.body?.getReader();
88 if (!reader) throw new CliError("Core returned an empty response.");
89 let size = 0;
90 const chunks: Uint8Array[] = [];
91 while (true) {
92 const { done, value } = await reader.read();
93 if (done) break;
94 size += value.byteLength;
95 if (size > 1048576) {
96 await reader.cancel();
97 throw new CliError("Core response exceeds 1 MiB.");
98 }
99 chunks.push(value);
100 }
101 const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
102 if (!record(parsed)) throw new CliError("Core returned an invalid JSON object.");
103 return parsed;
104 } catch (error) {
105 if (error instanceof CliError) throw error;
106 // Native fetch/JSON errors may include URLs, credentials or response text.
107 throw new CliError("Core request failed or timed out; inspect state before retrying writes.");
108 }
109 }
110}
111