@openafw/openafw 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRIVACY.md +377 -0
- package/README.md +152 -0
- package/dist/backends-Byh5VYtT.js +418 -0
- package/dist/bin/afw.js +45270 -0
- package/dist/bin/tap.js +83 -0
- package/dist/bin/tools.js +432 -0
- package/dist/secrets-9JqUBHyw.js +3 -0
- package/dist/secrets-Bj-gyv53.js +169 -0
- package/package.json +62 -0
- package/ui-dist/assets/index-BY6COSYk.css +1 -0
- package/ui-dist/assets/index-C9yCeZlD.js +20 -0
- package/ui-dist/index.html +13 -0
package/dist/bin/tap.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
//#region src/bin/tap.ts
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const sep = args.indexOf("--");
|
|
8
|
+
if (sep < 0) {
|
|
9
|
+
process.stderr.write("afw-tap: missing -- separator; expected --agent X --server Y -- <cmd> [args...]\n");
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
const flags = args.slice(0, sep);
|
|
13
|
+
const cmd = args[sep + 1];
|
|
14
|
+
const cmdArgs = args.slice(sep + 2);
|
|
15
|
+
if (!cmd) {
|
|
16
|
+
process.stderr.write("afw-tap: no command specified after --\n");
|
|
17
|
+
process.exit(2);
|
|
18
|
+
}
|
|
19
|
+
const AGENT = readFlag(flags, "--agent") ?? "unknown";
|
|
20
|
+
const SERVER = readFlag(flags, "--server") ?? "unknown";
|
|
21
|
+
const DAEMON_URL = process.env.AFW_DAEMON_URL ?? "http://localhost:9877";
|
|
22
|
+
const child = spawn(cmd, cmdArgs, { stdio: [
|
|
23
|
+
"pipe",
|
|
24
|
+
"pipe",
|
|
25
|
+
"inherit"
|
|
26
|
+
] });
|
|
27
|
+
if (!child.stdin || !child.stdout) {
|
|
28
|
+
process.stderr.write("afw-tap: failed to open child stdio\n");
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
forward(process.stdin, child.stdin, "request");
|
|
32
|
+
forward(child.stdout, process.stdout, "response");
|
|
33
|
+
child.on("exit", (code, signal) => {
|
|
34
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
35
|
+
});
|
|
36
|
+
child.on("error", (err) => {
|
|
37
|
+
process.stderr.write(`afw-tap: child error: ${err.message}\n`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
});
|
|
40
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
41
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
42
|
+
function forward(src, dst, direction) {
|
|
43
|
+
let buf = "";
|
|
44
|
+
src.on("data", (chunk) => {
|
|
45
|
+
dst.write(chunk);
|
|
46
|
+
buf += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
47
|
+
let nl;
|
|
48
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
49
|
+
const line = buf.slice(0, nl).trim();
|
|
50
|
+
buf = buf.slice(nl + 1);
|
|
51
|
+
if (line.length > 0) emitFrame(line, direction);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
src.on("end", () => {
|
|
55
|
+
if (buf.trim().length > 0) emitFrame(buf.trim(), direction);
|
|
56
|
+
dst.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function emitFrame(line, direction) {
|
|
60
|
+
let frame;
|
|
61
|
+
try {
|
|
62
|
+
frame = JSON.parse(line);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
fetch(`${DAEMON_URL}/api/tap/frame`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "content-type": "application/json" },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
agent: AGENT,
|
|
71
|
+
server: SERVER,
|
|
72
|
+
ts: Date.now(),
|
|
73
|
+
direction,
|
|
74
|
+
frame
|
|
75
|
+
})
|
|
76
|
+
}).catch(() => {});
|
|
77
|
+
}
|
|
78
|
+
function readFlag(flags$1, name) {
|
|
79
|
+
const i = flags$1.indexOf(name);
|
|
80
|
+
return i >= 0 && i + 1 < flags$1.length ? flags$1[i + 1] : void 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { getSecret, readSecrets } from "../secrets-Bj-gyv53.js";
|
|
3
|
+
import { activeProviderFor, readToolProviders, searchBaidu, searchBrave, searchDuckDuckGo } from "../backends-Byh5VYtT.js";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
//#region src/core/web-search/fetch.ts
|
|
7
|
+
const FETCH_USER_AGENT = "afw-tools/0.1 (+https://openafw.com)";
|
|
8
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
10
|
+
async function fetchUrl(opts) {
|
|
11
|
+
const policy = checkUrlPolicy(opts.url);
|
|
12
|
+
if (!policy.ok) return policy;
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
15
|
+
let res;
|
|
16
|
+
try {
|
|
17
|
+
res = await fetch(opts.url, {
|
|
18
|
+
headers: {
|
|
19
|
+
"user-agent": FETCH_USER_AGENT,
|
|
20
|
+
accept: "text/html, text/plain, */*"
|
|
21
|
+
},
|
|
22
|
+
redirect: "follow",
|
|
23
|
+
signal: controller.signal
|
|
24
|
+
});
|
|
25
|
+
} catch (err$1) {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `fetch failed: ${err$1.message}`
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
const finalPolicy = checkUrlPolicy(res.url);
|
|
34
|
+
if (!finalPolicy.ok) return finalPolicy;
|
|
35
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
36
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
37
|
+
const { text, truncated } = await readBounded(res, maxBytes);
|
|
38
|
+
const isHtml = /\bhtml\b/i.test(contentType) || /^\s*<!doctype html/i.test(text);
|
|
39
|
+
const extracted = isHtml ? htmlToText(text) : text.trim();
|
|
40
|
+
const title = isHtml ? extractTitle(text) : void 0;
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
url: opts.url,
|
|
44
|
+
finalUrl: res.url,
|
|
45
|
+
status: res.status,
|
|
46
|
+
contentType,
|
|
47
|
+
...title ? { title } : {},
|
|
48
|
+
text: extracted,
|
|
49
|
+
truncated
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Reject URLs that point at the local machine, private networks,
|
|
53
|
+
* link-local space, or cloud metadata endpoints. Operates on the URL
|
|
54
|
+
* string only (no DNS resolution) — for ip-literal hosts this is
|
|
55
|
+
* exact; for DNS names we still let the request go and re-check after
|
|
56
|
+
* redirect. Bypassing this via DNS rebinding would need a layered
|
|
57
|
+
* network policy (out of scope for v0). */
|
|
58
|
+
function checkUrlPolicy(rawUrl) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(rawUrl);
|
|
62
|
+
} catch {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "invalid url"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: `unsupported scheme: ${url.protocol}`
|
|
71
|
+
};
|
|
72
|
+
const host = url.hostname.toLowerCase();
|
|
73
|
+
if (BLOCKED_HOSTS.has(host)) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: `host "${host}" is on the local-network deny list`
|
|
76
|
+
};
|
|
77
|
+
if (isPrivateIpLiteral(host)) return {
|
|
78
|
+
ok: false,
|
|
79
|
+
error: `host "${host}" resolves to a private/local network`
|
|
80
|
+
};
|
|
81
|
+
return { ok: true };
|
|
82
|
+
}
|
|
83
|
+
const BLOCKED_HOSTS = new Set([
|
|
84
|
+
"localhost",
|
|
85
|
+
"localhost.localdomain",
|
|
86
|
+
"0.0.0.0",
|
|
87
|
+
"127.0.0.1",
|
|
88
|
+
"::1",
|
|
89
|
+
"169.254.169.254",
|
|
90
|
+
"metadata.google.internal"
|
|
91
|
+
]);
|
|
92
|
+
/** True if the host is an IPv4 or IPv6 literal inside a private /
|
|
93
|
+
* loopback / link-local range. Pure parse, no DNS. */
|
|
94
|
+
function isPrivateIpLiteral(host) {
|
|
95
|
+
if (host.includes(":")) {
|
|
96
|
+
if (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
100
|
+
if (!m) return false;
|
|
101
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
102
|
+
if (a === 10) return true;
|
|
103
|
+
if (a === 127) return true;
|
|
104
|
+
if (a === 169 && b === 254) return true;
|
|
105
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
106
|
+
if (a === 192 && b === 168) return true;
|
|
107
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
108
|
+
if (a === 0) return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
async function readBounded(res, maxBytes) {
|
|
112
|
+
if (!res.body) return {
|
|
113
|
+
text: "",
|
|
114
|
+
truncated: false
|
|
115
|
+
};
|
|
116
|
+
const reader = res.body.getReader();
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let total = 0;
|
|
119
|
+
let truncated = false;
|
|
120
|
+
while (true) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done) break;
|
|
123
|
+
if (!value) continue;
|
|
124
|
+
if (total + value.byteLength > maxBytes) {
|
|
125
|
+
const remaining = Math.max(0, maxBytes - total);
|
|
126
|
+
if (remaining > 0) chunks.push(value.subarray(0, remaining));
|
|
127
|
+
total += remaining;
|
|
128
|
+
truncated = true;
|
|
129
|
+
try {
|
|
130
|
+
await reader.cancel();
|
|
131
|
+
} catch {}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
chunks.push(value);
|
|
135
|
+
total += value.byteLength;
|
|
136
|
+
}
|
|
137
|
+
const merged = new Uint8Array(total);
|
|
138
|
+
let off = 0;
|
|
139
|
+
for (const c of chunks) {
|
|
140
|
+
merged.set(c, off);
|
|
141
|
+
off += c.byteLength;
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
text: new TextDecoder("utf-8", { fatal: false }).decode(merged),
|
|
145
|
+
truncated
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function extractTitle(html) {
|
|
149
|
+
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
150
|
+
if (!m?.[1]) return void 0;
|
|
151
|
+
const stripped = m[1].replace(/\s+/g, " ").trim();
|
|
152
|
+
return stripped || void 0;
|
|
153
|
+
}
|
|
154
|
+
/** Pretty-bad HTML→text extractor. Drops <script>/<style> contents
|
|
155
|
+
* entirely, strips remaining tags, decodes a handful of entities,
|
|
156
|
+
* collapses runs of whitespace. Good enough for "let the model read
|
|
157
|
+
* this page" — not for archival fidelity. Kept dependency-free so
|
|
158
|
+
* the binary stays small. */
|
|
159
|
+
function htmlToText(html) {
|
|
160
|
+
return decodeEntities(html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<(?:br|\/p|\/div|\/li|\/h[1-6])\s*[^>]*>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/[ \t]+/g, " ").replace(/\n[ \t]+/g, "\n").replace(/\n{3,}/g, "\n\n").trim());
|
|
161
|
+
}
|
|
162
|
+
function decodeEntities(s) {
|
|
163
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " ").replace(/&#(\d+);/g, (_, n) => {
|
|
164
|
+
const code = Number(n);
|
|
165
|
+
return Number.isFinite(code) && code >= 32 && code < 65536 ? String.fromCodePoint(code) : "";
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/bin/tools.ts
|
|
171
|
+
const SERVER_NAME = "afw-tools";
|
|
172
|
+
const SERVER_VERSION = "0.1.0";
|
|
173
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
174
|
+
const WEB_SEARCH_TOOL = {
|
|
175
|
+
name: "web_search",
|
|
176
|
+
description: "Search the web via the user-configured backend (DuckDuckGo, Brave, SearXNG, or Tavily). Returns a list of {title, url, snippet} results. Use when you need current information beyond your training cutoff or to locate a specific page.",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
query: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "The search query."
|
|
183
|
+
},
|
|
184
|
+
count: {
|
|
185
|
+
type: "number",
|
|
186
|
+
description: "Maximum number of results to return (default 10).",
|
|
187
|
+
minimum: 1,
|
|
188
|
+
maximum: 25
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
required: ["query"]
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const WEB_FETCH_TOOL = {
|
|
195
|
+
name: "web_fetch",
|
|
196
|
+
description: "Fetch a public web URL and return its title plus extracted text content. Blocks requests to localhost, private networks, and cloud metadata endpoints — the model cannot probe the user's intranet. Follows redirects; responses are truncated at ~1 MiB.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
url: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "The absolute http(s) URL to fetch."
|
|
203
|
+
},
|
|
204
|
+
maxBytes: {
|
|
205
|
+
type: "number",
|
|
206
|
+
description: "Cap on response bytes read (default 1048576).",
|
|
207
|
+
minimum: 1024,
|
|
208
|
+
maximum: 4 * 1024 * 1024
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
required: ["url"]
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function send(msg) {
|
|
215
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
216
|
+
}
|
|
217
|
+
function err(id, code, message) {
|
|
218
|
+
send({
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
id,
|
|
221
|
+
error: {
|
|
222
|
+
code,
|
|
223
|
+
message
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function dispatch(req) {
|
|
228
|
+
const id = req.id ?? null;
|
|
229
|
+
switch (req.method) {
|
|
230
|
+
case "initialize":
|
|
231
|
+
send({
|
|
232
|
+
jsonrpc: "2.0",
|
|
233
|
+
id,
|
|
234
|
+
result: {
|
|
235
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
236
|
+
serverInfo: {
|
|
237
|
+
name: SERVER_NAME,
|
|
238
|
+
version: SERVER_VERSION
|
|
239
|
+
},
|
|
240
|
+
capabilities: { tools: {} }
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
case "notifications/initialized": return;
|
|
245
|
+
case "tools/list":
|
|
246
|
+
send({
|
|
247
|
+
jsonrpc: "2.0",
|
|
248
|
+
id,
|
|
249
|
+
result: { tools: [WEB_SEARCH_TOOL, WEB_FETCH_TOOL] }
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
case "tools/call": {
|
|
253
|
+
const params = req.params ?? {};
|
|
254
|
+
if (params.name === "web_search") {
|
|
255
|
+
const args = params.arguments ?? {};
|
|
256
|
+
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
257
|
+
if (!query) {
|
|
258
|
+
err(id, -32602, "web_search: `query` is required");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const count = typeof args.count === "number" && args.count > 0 ? Math.min(25, args.count) : 10;
|
|
262
|
+
const outcome = await runWebSearch({
|
|
263
|
+
query,
|
|
264
|
+
count
|
|
265
|
+
});
|
|
266
|
+
send({
|
|
267
|
+
jsonrpc: "2.0",
|
|
268
|
+
id,
|
|
269
|
+
result: toSearchToolResult(outcome)
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (params.name === "web_fetch") {
|
|
274
|
+
const args = params.arguments ?? {};
|
|
275
|
+
const url = typeof args.url === "string" ? args.url.trim() : "";
|
|
276
|
+
if (!url) {
|
|
277
|
+
err(id, -32602, "web_fetch: `url` is required");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const maxBytes = typeof args.maxBytes === "number" && args.maxBytes > 0 ? args.maxBytes : void 0;
|
|
281
|
+
const outcome = await fetchUrl({
|
|
282
|
+
url,
|
|
283
|
+
...maxBytes ? { maxBytes } : {}
|
|
284
|
+
});
|
|
285
|
+
send({
|
|
286
|
+
jsonrpc: "2.0",
|
|
287
|
+
id,
|
|
288
|
+
result: toFetchToolResult(outcome)
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
err(id, -32601, `unknown tool: ${String(params.name)}`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
case "ping":
|
|
296
|
+
send({
|
|
297
|
+
jsonrpc: "2.0",
|
|
298
|
+
id,
|
|
299
|
+
result: {}
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
default: err(id, -32601, `method not implemented: ${req.method}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async function runWebSearch(opts) {
|
|
306
|
+
let provider;
|
|
307
|
+
try {
|
|
308
|
+
const store = await readToolProviders();
|
|
309
|
+
provider = activeProviderFor(store, "web_search");
|
|
310
|
+
} catch (e) {
|
|
311
|
+
return {
|
|
312
|
+
ok: false,
|
|
313
|
+
error: `tool-providers.json unreadable: ${e.message}`
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (!provider) return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: "no web_search backend configured — add one in Control · Tool Providers"
|
|
319
|
+
};
|
|
320
|
+
switch (provider.backend) {
|
|
321
|
+
case "duckduckgo": return searchDuckDuckGo({
|
|
322
|
+
query: opts.query,
|
|
323
|
+
count: opts.count
|
|
324
|
+
});
|
|
325
|
+
case "brave": {
|
|
326
|
+
const apiKey = await resolveSecret(provider);
|
|
327
|
+
if (!apiKey) return {
|
|
328
|
+
ok: false,
|
|
329
|
+
error: `provider "${provider.id}" is missing its API key`
|
|
330
|
+
};
|
|
331
|
+
return searchBrave({
|
|
332
|
+
query: opts.query,
|
|
333
|
+
count: opts.count,
|
|
334
|
+
apiKey
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
case "baidu": {
|
|
338
|
+
const apiKey = await resolveSecret(provider);
|
|
339
|
+
if (!apiKey) return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: `provider "${provider.id}" is missing its API key`
|
|
342
|
+
};
|
|
343
|
+
return searchBaidu({
|
|
344
|
+
query: opts.query,
|
|
345
|
+
count: opts.count,
|
|
346
|
+
apiKey
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
case "searxng":
|
|
350
|
+
case "tavily": return {
|
|
351
|
+
ok: false,
|
|
352
|
+
error: `backend "${provider.backend}" is not implemented yet`
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
async function resolveSecret(p) {
|
|
357
|
+
if (!p.authRef) return void 0;
|
|
358
|
+
try {
|
|
359
|
+
const secrets = await readSecrets();
|
|
360
|
+
return getSecret(secrets, p.authRef) ?? void 0;
|
|
361
|
+
} catch {
|
|
362
|
+
return void 0;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function toSearchToolResult(outcome) {
|
|
366
|
+
if (!outcome.ok) return {
|
|
367
|
+
isError: true,
|
|
368
|
+
content: [{
|
|
369
|
+
type: "text",
|
|
370
|
+
text: `web_search failed: ${outcome.error}`
|
|
371
|
+
}]
|
|
372
|
+
};
|
|
373
|
+
return { content: [{
|
|
374
|
+
type: "text",
|
|
375
|
+
text: formatResults(outcome.results)
|
|
376
|
+
}] };
|
|
377
|
+
}
|
|
378
|
+
function toFetchToolResult(outcome) {
|
|
379
|
+
if (!outcome.ok) return {
|
|
380
|
+
isError: true,
|
|
381
|
+
content: [{
|
|
382
|
+
type: "text",
|
|
383
|
+
text: `web_fetch failed: ${outcome.error}`
|
|
384
|
+
}]
|
|
385
|
+
};
|
|
386
|
+
const header = [
|
|
387
|
+
`URL: ${outcome.finalUrl}${outcome.finalUrl !== outcome.url ? ` (from ${outcome.url})` : ""}`,
|
|
388
|
+
`Status: ${outcome.status}`,
|
|
389
|
+
outcome.contentType ? `Content-Type: ${outcome.contentType}` : "",
|
|
390
|
+
outcome.title ? `Title: ${outcome.title}` : "",
|
|
391
|
+
outcome.truncated ? "(response truncated at maxBytes)" : ""
|
|
392
|
+
].filter(Boolean).join("\n");
|
|
393
|
+
return { content: [{
|
|
394
|
+
type: "text",
|
|
395
|
+
text: `${header}\n\n${outcome.text}`
|
|
396
|
+
}] };
|
|
397
|
+
}
|
|
398
|
+
function formatResults(results) {
|
|
399
|
+
if (results.length === 0) return "No results.";
|
|
400
|
+
const lines = [];
|
|
401
|
+
results.forEach((r, i) => {
|
|
402
|
+
lines.push(`${i + 1}. ${r.title}`);
|
|
403
|
+
lines.push(` ${r.url}`);
|
|
404
|
+
if (r.snippet) lines.push(` ${r.snippet}`);
|
|
405
|
+
lines.push("");
|
|
406
|
+
});
|
|
407
|
+
return lines.join("\n").trimEnd();
|
|
408
|
+
}
|
|
409
|
+
let buf = "";
|
|
410
|
+
process.stdin.setEncoding("utf8");
|
|
411
|
+
process.stdin.on("data", (chunk) => {
|
|
412
|
+
buf += chunk;
|
|
413
|
+
let nl;
|
|
414
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
415
|
+
const line = buf.slice(0, nl).trim();
|
|
416
|
+
buf = buf.slice(nl + 1);
|
|
417
|
+
if (!line) continue;
|
|
418
|
+
let req;
|
|
419
|
+
try {
|
|
420
|
+
req = JSON.parse(line);
|
|
421
|
+
} catch (e) {
|
|
422
|
+
err(null, -32700, `parse error: ${e.message}`);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
dispatch(req).catch((e) => err(req.id ?? null, -32e3, e.message));
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
process.stdin.on("end", () => {
|
|
429
|
+
process.exit(0);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { access, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
//#region src/core/paths.ts
|
|
7
|
+
const HOME = homedir();
|
|
8
|
+
/** Resolve the afw home directory. Honors an explicit `AFW_HOME`
|
|
9
|
+
* override; otherwise `~/.afw`. */
|
|
10
|
+
function resolveHome() {
|
|
11
|
+
return process.env.AFW_HOME ?? join(HOME, ".afw");
|
|
12
|
+
}
|
|
13
|
+
const AFW_HOME = resolveHome();
|
|
14
|
+
const paths = {
|
|
15
|
+
home: AFW_HOME,
|
|
16
|
+
wire: {
|
|
17
|
+
dir: join(AFW_HOME, "wire"),
|
|
18
|
+
routes: join(AFW_HOME, "wire", "routes.json"),
|
|
19
|
+
traces: join(AFW_HOME, "wire", "traces"),
|
|
20
|
+
tracesArchive: join(AFW_HOME, "wire", "traces", "archive"),
|
|
21
|
+
daemonSock: join(AFW_HOME, "wire", "daemon.sock"),
|
|
22
|
+
daemonPid: join(AFW_HOME, "wire", "daemon.pid")
|
|
23
|
+
},
|
|
24
|
+
backups: {
|
|
25
|
+
dir: join(AFW_HOME, "backups"),
|
|
26
|
+
manifest: join(AFW_HOME, "backups", "manifest.json")
|
|
27
|
+
},
|
|
28
|
+
logs: {
|
|
29
|
+
dir: join(AFW_HOME, "logs"),
|
|
30
|
+
daemon: join(AFW_HOME, "logs", "daemon.log"),
|
|
31
|
+
daemonErr: join(AFW_HOME, "logs", "daemon.err")
|
|
32
|
+
},
|
|
33
|
+
config: join(AFW_HOME, "config.json"),
|
|
34
|
+
update: join(AFW_HOME, "update.json"),
|
|
35
|
+
models: join(AFW_HOME, "models.json"),
|
|
36
|
+
routing: join(AFW_HOME, "routing.json"),
|
|
37
|
+
secrets: join(AFW_HOME, "secrets.json"),
|
|
38
|
+
keys: join(AFW_HOME, "keys.json"),
|
|
39
|
+
tiers: join(AFW_HOME, "tiers.json"),
|
|
40
|
+
masking: join(AFW_HOME, "masking.json"),
|
|
41
|
+
toolProviders: join(AFW_HOME, "tool-providers.json"),
|
|
42
|
+
agent: {
|
|
43
|
+
claudeCode: {
|
|
44
|
+
settings: join(HOME, ".claude", "settings.json"),
|
|
45
|
+
legacy: join(HOME, ".claude.json")
|
|
46
|
+
},
|
|
47
|
+
claudeDesktop: {
|
|
48
|
+
root: join(HOME, "Library", "Application Support", "Claude"),
|
|
49
|
+
mcpConfig: join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
50
|
+
},
|
|
51
|
+
openclaw: join(HOME, ".openclaw", "openclaw.json"),
|
|
52
|
+
opencode: join(HOME, ".config", "opencode", "opencode.json"),
|
|
53
|
+
hermes: {
|
|
54
|
+
config: join(HOME, ".hermes", "config.yaml"),
|
|
55
|
+
env: join(HOME, ".hermes", ".env")
|
|
56
|
+
},
|
|
57
|
+
codex: {
|
|
58
|
+
config: join(HOME, ".codex", "config.toml"),
|
|
59
|
+
auth: join(HOME, ".codex", "auth.json")
|
|
60
|
+
},
|
|
61
|
+
cursor: {
|
|
62
|
+
darwin: join(HOME, "Library", "Application Support", "Cursor", "User", "settings.json"),
|
|
63
|
+
linux: join(HOME, ".config", "Cursor", "User", "settings.json")
|
|
64
|
+
},
|
|
65
|
+
gemini: join(HOME, ".gemini", ".env")
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const PRICING_OVERRIDE = join(AFW_HOME, "pricing.json");
|
|
69
|
+
const PRICING_CATALOG_CACHE = join(AFW_HOME, "pricing-catalog.json");
|
|
70
|
+
const DAEMON_PORT = (() => {
|
|
71
|
+
const p = process.env.AFW_PORT;
|
|
72
|
+
return p ? Number.parseInt(p, 10) : 9877;
|
|
73
|
+
})();
|
|
74
|
+
const DAEMON_HOST = "localhost";
|
|
75
|
+
const DAEMON_BASE_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/core/atomic-file.ts
|
|
79
|
+
async function atomicWrite(path, content, opts) {
|
|
80
|
+
await mkdir(dirname(path), { recursive: true });
|
|
81
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
82
|
+
await writeFile(tmp, content);
|
|
83
|
+
if (opts?.mode != null) await chmod(tmp, opts.mode);
|
|
84
|
+
await rename(tmp, path);
|
|
85
|
+
}
|
|
86
|
+
async function fileExists(path) {
|
|
87
|
+
try {
|
|
88
|
+
await access(path);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/core/secrets.ts
|
|
97
|
+
const SECRETS_VERSION = 1;
|
|
98
|
+
const SECRETS_MODE = 384;
|
|
99
|
+
const EMPTY_SECRETS = {
|
|
100
|
+
version: SECRETS_VERSION,
|
|
101
|
+
secrets: {}
|
|
102
|
+
};
|
|
103
|
+
function getSecret(store, ref) {
|
|
104
|
+
return store.secrets[ref];
|
|
105
|
+
}
|
|
106
|
+
/** Refs present in the store, for the UI to show which keys are configured
|
|
107
|
+
* (it never receives the values themselves). */
|
|
108
|
+
function secretRefs(store) {
|
|
109
|
+
return Object.keys(store.secrets);
|
|
110
|
+
}
|
|
111
|
+
function isObj(v) {
|
|
112
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
113
|
+
}
|
|
114
|
+
function normalizeSecretStore(raw) {
|
|
115
|
+
if (!isObj(raw)) return { ...EMPTY_SECRETS };
|
|
116
|
+
if (raw.version !== SECRETS_VERSION) throw new Error(`secrets.json version ${String(raw.version)} not supported (expected ${SECRETS_VERSION})`);
|
|
117
|
+
const secrets = {};
|
|
118
|
+
if (isObj(raw.secrets)) {
|
|
119
|
+
for (const [ref, value] of Object.entries(raw.secrets)) if (typeof value === "string") secrets[ref] = value;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
version: SECRETS_VERSION,
|
|
123
|
+
secrets
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function readSecrets() {
|
|
127
|
+
if (!await fileExists(paths.secrets)) return { ...EMPTY_SECRETS };
|
|
128
|
+
return normalizeSecretStore(JSON.parse(await readFile(paths.secrets, "utf8")));
|
|
129
|
+
}
|
|
130
|
+
async function writeSecrets(store) {
|
|
131
|
+
await atomicWrite(paths.secrets, `${JSON.stringify(store, null, 2)}\n`, { mode: SECRETS_MODE });
|
|
132
|
+
}
|
|
133
|
+
let writeChain = Promise.resolve();
|
|
134
|
+
/** Serialized read-modify-write — see model-registry.ts mutateModelRegistry. */
|
|
135
|
+
function mutateSecrets(fn) {
|
|
136
|
+
const next = writeChain.then(async () => {
|
|
137
|
+
const store = await readSecrets();
|
|
138
|
+
const updated = fn(store);
|
|
139
|
+
if (updated) await writeSecrets(updated);
|
|
140
|
+
return updated ?? store;
|
|
141
|
+
});
|
|
142
|
+
writeChain = next.catch(() => {});
|
|
143
|
+
return next;
|
|
144
|
+
}
|
|
145
|
+
/** Store a secret value under a ref. */
|
|
146
|
+
function setSecret(ref, value) {
|
|
147
|
+
return mutateSecrets((store) => ({
|
|
148
|
+
...store,
|
|
149
|
+
secrets: {
|
|
150
|
+
...store.secrets,
|
|
151
|
+
[ref]: value
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
/** Remove a secret. No-op if the ref is absent. */
|
|
156
|
+
function removeSecret(ref) {
|
|
157
|
+
return mutateSecrets((store) => {
|
|
158
|
+
if (!(ref in store.secrets)) return void 0;
|
|
159
|
+
const secrets = { ...store.secrets };
|
|
160
|
+
delete secrets[ref];
|
|
161
|
+
return {
|
|
162
|
+
...store,
|
|
163
|
+
secrets
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
//#endregion
|
|
169
|
+
export { AFW_HOME, DAEMON_BASE_URL, DAEMON_PORT, EMPTY_SECRETS, PRICING_CATALOG_CACHE, PRICING_OVERRIDE, SECRETS_VERSION, atomicWrite, fileExists, getSecret, mutateSecrets, normalizeSecretStore, paths, readSecrets, removeSecret, secretRefs, setSecret, writeSecrets };
|