driggsby 0.1.41 → 0.2.0
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/README.md +59 -0
- package/bin/driggsby.js +2 -0
- package/dist/api/base-url.js +31 -0
- package/dist/api/network-error.js +55 -0
- package/dist/args-deploy.js +94 -0
- package/dist/args-init.js +29 -0
- package/dist/args-mcp-setup.js +258 -0
- package/dist/args-shared.js +28 -0
- package/dist/args.js +177 -0
- package/dist/clap-suggestions.js +66 -0
- package/dist/cli-error.js +12 -0
- package/dist/credentials/clear-outcome.js +1 -0
- package/dist/credentials/file-store.js +77 -0
- package/dist/credentials/keychain.js +54 -0
- package/dist/credentials/secret-tool.js +48 -0
- package/dist/credentials/store.js +164 -0
- package/dist/credentials/tool-runner.js +55 -0
- package/dist/deploy/api-session.js +85 -0
- package/dist/deploy/deploy-command.js +126 -0
- package/dist/deploy/rollback-command.js +100 -0
- package/dist/deploy/versions-command.js +97 -0
- package/dist/dev/dev-command.js +106 -0
- package/dist/dev/dev-servers.js +274 -0
- package/dist/dev/host-page.js +147 -0
- package/dist/dev/mcp-broker.js +149 -0
- package/dist/dev/static-files.js +104 -0
- package/dist/dev/tool-allowlist.js +18 -0
- package/dist/dev/watcher.js +76 -0
- package/dist/help.js +180 -0
- package/dist/init/init-command.js +114 -0
- package/dist/init/templates.js +271 -0
- package/dist/login/claim-client.js +151 -0
- package/dist/login/login.js +146 -0
- package/dist/login/logout.js +37 -0
- package/dist/login/open-url.js +79 -0
- package/dist/main.js +57 -0
- package/dist/mcp-setup/classify.js +37 -0
- package/dist/mcp-setup/client-command.js +133 -0
- package/dist/mcp-setup/commands.js +53 -0
- package/dist/mcp-setup/known-client.js +38 -0
- package/dist/mcp-setup/setup.js +137 -0
- package/dist/mcp-setup/spawn-plan.js +91 -0
- package/dist/mcp-setup/text.js +83 -0
- package/dist/terminal-text.js +19 -0
- package/dist/version.js +6 -0
- package/package.json +22 -41
- package/bin/driggsby +0 -10
- package/install.js +0 -159
- package/lib/artifacts.js +0 -49
- package/lib/process.js +0 -65
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The two local servers behind `driggsby dev`, mirroring production's
|
|
2
|
+
// topology: the host origin (the page a person opens; serves the host page
|
|
3
|
+
// and the CLI's tool-call endpoint) and the app origin (the app's own
|
|
4
|
+
// files, plus the Driggsby SDK at /-/driggsby-sdk.js). Both bind loopback
|
|
5
|
+
// only. The app token never appears here — the broker callback holds it in
|
|
6
|
+
// the CLI process.
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { hostPageHtml, HOST_PAGE_JS } from "./host-page.js";
|
|
9
|
+
import { serveStaticFile } from "./static-files.js";
|
|
10
|
+
import { APP_TOOL_ALLOWLIST } from "./tool-allowlist.js";
|
|
11
|
+
// The one wording for "this call didn't happen" cases the app can't fix by
|
|
12
|
+
// changing its code (overload, network trouble). Tool-specific refusals get
|
|
13
|
+
// their own messages.
|
|
14
|
+
export const GENERIC_TOOL_TROUBLE = "We couldn't run that just now. Try again in a moment.";
|
|
15
|
+
export const TOOL_NOT_ALLOWED_MESSAGE = "That tool isn't available to Driggsby apps. Apps can use read-only tools like get_overview and list_accounts.";
|
|
16
|
+
const MAX_TOOL_CALL_BODY_BYTES = 65_536;
|
|
17
|
+
export async function startDevServers(options) {
|
|
18
|
+
// Both origins bind and advertise 127.0.0.1: advertising "localhost"
|
|
19
|
+
// while binding only the IPv4 loopback breaks on machines whose browser
|
|
20
|
+
// resolves localhost to ::1 first.
|
|
21
|
+
let appPortForGuard = 0;
|
|
22
|
+
// Filled in once the host server binds; app responses embed it in their
|
|
23
|
+
// frame-ancestors policy so only the real host page can frame the app.
|
|
24
|
+
let hostOriginForFraming = "";
|
|
25
|
+
const appServer = createServer((request, response) => {
|
|
26
|
+
void handleAppRequest(options, appPortForGuard, hostOriginForFraming, request, response);
|
|
27
|
+
});
|
|
28
|
+
await listenLoopback(appServer, options.appPort);
|
|
29
|
+
const appPort = boundPort(appServer);
|
|
30
|
+
appPortForGuard = appPort;
|
|
31
|
+
const appOrigin = `http://127.0.0.1:${String(appPort)}`;
|
|
32
|
+
const sseClients = new Set();
|
|
33
|
+
let hostPort = 0;
|
|
34
|
+
const hostServer = createServer((request, response) => {
|
|
35
|
+
void handleHostRequest(options, hostPort, appOrigin, sseClients, request, response);
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await listenLoopback(hostServer, options.hostPort);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
// The app server is already listening; a failed host bind must not
|
|
42
|
+
// leave it squatting its port while the command reports failure.
|
|
43
|
+
await closeServer(appServer);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
hostPort = boundPort(hostServer);
|
|
47
|
+
hostOriginForFraming = `http://127.0.0.1:${String(hostPort)}`;
|
|
48
|
+
return {
|
|
49
|
+
hostOrigin: hostOriginForFraming,
|
|
50
|
+
appOrigin,
|
|
51
|
+
hostPort,
|
|
52
|
+
appPort,
|
|
53
|
+
notifyChange: () => {
|
|
54
|
+
for (const client of sseClients) {
|
|
55
|
+
client.write("event: change\ndata: files-changed\n\n");
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
close: async () => {
|
|
59
|
+
for (const client of sseClients) {
|
|
60
|
+
client.end();
|
|
61
|
+
}
|
|
62
|
+
sseClients.clear();
|
|
63
|
+
await Promise.all([closeServer(hostServer), closeServer(appServer)]);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// The app origin: the SDK path, then the project's own files.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
async function handleAppRequest(options, appPort, hostOrigin, request, response) {
|
|
71
|
+
// Only the real host page may frame the app: without this, any other
|
|
72
|
+
// loopback-served page could embed the app origin, pin itself as the
|
|
73
|
+
// app's host, and feed it fabricated numbers. Until the host server has
|
|
74
|
+
// bound (a startup race measured in milliseconds), nothing may frame it.
|
|
75
|
+
// nosniff keeps the browser honest about the served content types.
|
|
76
|
+
const appHeaders = {
|
|
77
|
+
"Content-Security-Policy": `frame-ancestors ${hostOrigin === "" ? "'none'" : hostOrigin};`,
|
|
78
|
+
"X-Content-Type-Options": "nosniff",
|
|
79
|
+
};
|
|
80
|
+
// Same rebinding guard as the host origin: a page on another site that
|
|
81
|
+
// tricked DNS into pointing here carries its own Host, and fails.
|
|
82
|
+
if (!hostHeaderIsLocal(request.headers.host, appPort)) {
|
|
83
|
+
sendText(response, 403, "text/plain; charset=utf-8", "Forbidden.", appHeaders);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const path = requestPath(request);
|
|
87
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
88
|
+
sendText(response, 405, "text/plain; charset=utf-8", "Method not allowed.", appHeaders);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (path === "/-/driggsby-sdk.js") {
|
|
92
|
+
sendText(response, 200, "text/javascript; charset=utf-8", options.sdkBundle, appHeaders);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const file = await serveStaticFile(options.serveDirectory, path);
|
|
96
|
+
if (file === null) {
|
|
97
|
+
sendText(response, 404, "text/plain; charset=utf-8", "Not found.", appHeaders);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
response.writeHead(file.status, {
|
|
101
|
+
"Content-Type": file.contentType,
|
|
102
|
+
"Cache-Control": "no-store",
|
|
103
|
+
...appHeaders,
|
|
104
|
+
});
|
|
105
|
+
response.end(file.body);
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// The host origin: page, script, change events, tool calls.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
async function handleHostRequest(options, hostPort, appOrigin, sseClients, request, response) {
|
|
111
|
+
// Rebinding guard: every host-origin route answers only when the browser
|
|
112
|
+
// thinks it is talking to this local origin. A page on some other site
|
|
113
|
+
// that tricked DNS into pointing here carries its own Host, and fails.
|
|
114
|
+
if (!hostHeaderIsLocal(request.headers.host, hostPort)) {
|
|
115
|
+
sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const path = requestPath(request);
|
|
119
|
+
if (request.method === "GET" && path === "/") {
|
|
120
|
+
sendHostPage(response, hostPageHtml(options.slug, appOrigin), appOrigin);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (request.method === "GET" && path === "/host.js") {
|
|
124
|
+
sendText(response, 200, "text/javascript; charset=utf-8", HOST_PAGE_JS);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (request.method === "GET" && path === "/events") {
|
|
128
|
+
response.writeHead(200, {
|
|
129
|
+
"Content-Type": "text/event-stream",
|
|
130
|
+
"Cache-Control": "no-store",
|
|
131
|
+
"X-Content-Type-Options": "nosniff",
|
|
132
|
+
Connection: "keep-alive",
|
|
133
|
+
});
|
|
134
|
+
response.write(": connected\n\n");
|
|
135
|
+
sseClients.add(response);
|
|
136
|
+
request.on("close", () => {
|
|
137
|
+
sseClients.delete(response);
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (request.method === "POST" && path === "/tool-calls") {
|
|
142
|
+
await handleToolCall(options, hostPort, request, response);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
sendText(response, 404, "text/plain; charset=utf-8", "Not found.");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
async function handleToolCall(options, hostPort, request, response) {
|
|
149
|
+
// Cross-site guard: the browser stamps a page's own origin onto
|
|
150
|
+
// cross-origin POSTs. Only the host page itself (same-origin fetch: no
|
|
151
|
+
// Origin header, or the host origin) may run tools.
|
|
152
|
+
const origin = request.headers.origin;
|
|
153
|
+
if (origin !== undefined && !originIsLocalHost(origin, hostPort)) {
|
|
154
|
+
sendJson(response, 403, { ok: false, error: { message: "Forbidden." } });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Defense in depth for callers that carry no Origin at all: a cross-site
|
|
158
|
+
// "simple" POST (text/plain or form-encoded) never has this content type,
|
|
159
|
+
// and a cross-origin JSON POST triggers a preflight this server never
|
|
160
|
+
// grants (no CORS headers, OPTIONS is a 404). The host page always sends
|
|
161
|
+
// JSON, so nothing legitimate is refused.
|
|
162
|
+
const contentType = request.headers["content-type"] ?? "";
|
|
163
|
+
if (!contentType.toLowerCase().startsWith("application/json")) {
|
|
164
|
+
sendJson(response, 415, { ok: false, error: { message: GENERIC_TOOL_TROUBLE } });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const body = await readBody(request, MAX_TOOL_CALL_BODY_BYTES);
|
|
168
|
+
if (body === null) {
|
|
169
|
+
sendJson(response, 413, { ok: false, error: { message: GENERIC_TOOL_TROUBLE } });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(body);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
sendJson(response, 400, { ok: false, error: { message: GENERIC_TOOL_TROUBLE } });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const record = typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
181
|
+
const tool = record.tool;
|
|
182
|
+
if (typeof tool !== "string" || tool.length === 0 || tool.length > 128) {
|
|
183
|
+
sendJson(response, 400, { ok: false, error: { message: GENERIC_TOOL_TROUBLE } });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!APP_TOOL_ALLOWLIST.has(tool)) {
|
|
187
|
+
sendJson(response, 200, { ok: false, error: { message: TOOL_NOT_ALLOWED_MESSAGE } });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const argumentsObject = typeof record.arguments === "object" && record.arguments !== null && !Array.isArray(record.arguments)
|
|
191
|
+
? record.arguments
|
|
192
|
+
: {};
|
|
193
|
+
const result = await options.runToolCall(tool, argumentsObject);
|
|
194
|
+
sendJson(response, 200, result);
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Small pieces.
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
function requestPath(request) {
|
|
200
|
+
const url = request.url ?? "/";
|
|
201
|
+
const queryStart = url.indexOf("?");
|
|
202
|
+
return queryStart === -1 ? url : url.slice(0, queryStart);
|
|
203
|
+
}
|
|
204
|
+
function hostHeaderIsLocal(host, port) {
|
|
205
|
+
return host === `localhost:${String(port)}` || host === `127.0.0.1:${String(port)}`;
|
|
206
|
+
}
|
|
207
|
+
function originIsLocalHost(origin, port) {
|
|
208
|
+
return (origin === `http://localhost:${String(port)}` || origin === `http://127.0.0.1:${String(port)}`);
|
|
209
|
+
}
|
|
210
|
+
async function readBody(request, maxBytes) {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
let total = 0;
|
|
213
|
+
for await (const chunk of request) {
|
|
214
|
+
const buffer = chunk;
|
|
215
|
+
total += buffer.length;
|
|
216
|
+
if (total > maxBytes) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
chunks.push(buffer);
|
|
220
|
+
}
|
|
221
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
222
|
+
}
|
|
223
|
+
function sendText(response, status, contentType, body, extraHeaders = {}) {
|
|
224
|
+
// nosniff on every response from either origin: the served content types
|
|
225
|
+
// are always explicit, so the browser never needs to guess.
|
|
226
|
+
response.writeHead(status, {
|
|
227
|
+
"Content-Type": contentType,
|
|
228
|
+
"Cache-Control": "no-store",
|
|
229
|
+
"X-Content-Type-Options": "nosniff",
|
|
230
|
+
...extraHeaders,
|
|
231
|
+
});
|
|
232
|
+
response.end(body);
|
|
233
|
+
}
|
|
234
|
+
function sendJson(response, status, payload) {
|
|
235
|
+
sendText(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
|
|
236
|
+
}
|
|
237
|
+
function sendHostPage(response, html, appOrigin) {
|
|
238
|
+
response.writeHead(200, {
|
|
239
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
240
|
+
"Cache-Control": "no-store",
|
|
241
|
+
"X-Content-Type-Options": "nosniff",
|
|
242
|
+
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; " +
|
|
243
|
+
`connect-src 'self'; frame-src ${appOrigin}; frame-ancestors 'none';`,
|
|
244
|
+
});
|
|
245
|
+
response.end(html);
|
|
246
|
+
}
|
|
247
|
+
async function listenLoopback(server, port) {
|
|
248
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
249
|
+
server.once("error", rejectPromise);
|
|
250
|
+
server.listen(port, "127.0.0.1", () => {
|
|
251
|
+
server.removeListener("error", rejectPromise);
|
|
252
|
+
// A server-level error after listening (EMFILE, a connection reset
|
|
253
|
+
// mid-accept) affects one connection; without a handler it would be
|
|
254
|
+
// an unhandled 'error' event that crashes the whole dev session.
|
|
255
|
+
server.on("error", () => undefined);
|
|
256
|
+
resolvePromise();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
function boundPort(server) {
|
|
261
|
+
const address = server.address();
|
|
262
|
+
if (address === null || typeof address === "string") {
|
|
263
|
+
throw new Error("dev server has no bound port");
|
|
264
|
+
}
|
|
265
|
+
return address.port;
|
|
266
|
+
}
|
|
267
|
+
async function closeServer(server) {
|
|
268
|
+
server.closeAllConnections();
|
|
269
|
+
await new Promise((resolvePromise) => {
|
|
270
|
+
server.close(() => {
|
|
271
|
+
resolvePromise();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The dev host page: what a browser shows at the host origin. It embeds the
|
|
2
|
+
// app from the app origin in a cross-origin iframe and speaks the host side
|
|
3
|
+
// of the frozen driggsby-sdk/1 protocol — exactly the topology the deployed
|
|
4
|
+
// app lives in, so an app that works under `driggsby dev` works identically
|
|
5
|
+
// inside Driggsby. The page carries no token and no data; every tool call
|
|
6
|
+
// goes to the CLI's own /tool-calls endpoint, which holds the token and
|
|
7
|
+
// enforces the allowlist and bounds.
|
|
8
|
+
// The page's script is served as its own file so the page can carry a
|
|
9
|
+
// Content-Security-Policy with no inline scripts.
|
|
10
|
+
//
|
|
11
|
+
// Both interpolations below are HTML-safe by construction, not by escaping:
|
|
12
|
+
// `slug` has passed readProjectConfig's strict slug rules (lowercase
|
|
13
|
+
// alphanumerics and dashes only) before runDev ever calls this, and
|
|
14
|
+
// `appOrigin` is built by startDevServers from a literal plus a bound port
|
|
15
|
+
// number. Anything looser than those two sources must not be passed here.
|
|
16
|
+
export function hostPageHtml(slug, appOrigin) {
|
|
17
|
+
return `<!doctype html>
|
|
18
|
+
<html lang="en" data-app-origin="${appOrigin}">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="utf-8">
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
22
|
+
<title>${slug} — driggsby dev</title>
|
|
23
|
+
<style>
|
|
24
|
+
* { box-sizing: border-box; }
|
|
25
|
+
body {
|
|
26
|
+
margin: 0;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
height: 100vh;
|
|
30
|
+
background: #ffffff;
|
|
31
|
+
color: #1f2430;
|
|
32
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
33
|
+
font-size: 14px;
|
|
34
|
+
}
|
|
35
|
+
header {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: baseline;
|
|
38
|
+
justify-content: space-between;
|
|
39
|
+
padding: 10px 16px;
|
|
40
|
+
border-bottom: 1px solid #e6e8ec;
|
|
41
|
+
}
|
|
42
|
+
header .slug { font-weight: 600; }
|
|
43
|
+
header .mode { color: #6d7280; font-size: 13px; }
|
|
44
|
+
iframe {
|
|
45
|
+
flex: 1;
|
|
46
|
+
width: 100%;
|
|
47
|
+
border: none;
|
|
48
|
+
}
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<header>
|
|
53
|
+
<span class="slug">${slug}</span>
|
|
54
|
+
<span class="mode">driggsby dev — your live data</span>
|
|
55
|
+
</header>
|
|
56
|
+
<iframe id="app" src="${appOrigin}/" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
|
57
|
+
<script type="module" src="/host.js"></script>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
// The host-side protocol runtime. Kept dependency-free and template-free so
|
|
63
|
+
// nothing needs escaping: this string is served verbatim as /host.js.
|
|
64
|
+
export const HOST_PAGE_JS = [
|
|
65
|
+
'const PROTOCOL = "driggsby-sdk/1";',
|
|
66
|
+
"const APP_ORIGIN = document.documentElement.dataset.appOrigin;",
|
|
67
|
+
'const frame = document.getElementById("app");',
|
|
68
|
+
"",
|
|
69
|
+
"function postToApp(message) {",
|
|
70
|
+
" if (frame.contentWindow) {",
|
|
71
|
+
" frame.contentWindow.postMessage(message, APP_ORIGIN);",
|
|
72
|
+
" }",
|
|
73
|
+
"}",
|
|
74
|
+
"",
|
|
75
|
+
"// Only the embedded app may speak: right window, right origin, our",
|
|
76
|
+
"// protocol marker.",
|
|
77
|
+
"function messageFromApp(event) {",
|
|
78
|
+
" return (",
|
|
79
|
+
" event.origin === APP_ORIGIN &&",
|
|
80
|
+
" event.source === frame.contentWindow &&",
|
|
81
|
+
" typeof event.data === \"object\" &&",
|
|
82
|
+
" event.data !== null &&",
|
|
83
|
+
" event.data.protocol === PROTOCOL",
|
|
84
|
+
" );",
|
|
85
|
+
"}",
|
|
86
|
+
"",
|
|
87
|
+
"async function runToolCall(id, tool, params) {",
|
|
88
|
+
" const body = JSON.stringify({ tool: tool, arguments: params });",
|
|
89
|
+
" let payload = null;",
|
|
90
|
+
" try {",
|
|
91
|
+
' const response = await fetch("/tool-calls", {',
|
|
92
|
+
' method: "POST",',
|
|
93
|
+
' headers: { "Content-Type": "application/json" },',
|
|
94
|
+
" body: body,",
|
|
95
|
+
" });",
|
|
96
|
+
" payload = await response.json();",
|
|
97
|
+
" } catch (error) {",
|
|
98
|
+
" payload = null;",
|
|
99
|
+
" }",
|
|
100
|
+
" if (payload && payload.ok === true) {",
|
|
101
|
+
' postToApp({ protocol: PROTOCOL, type: "result", id: id, result: payload.result });',
|
|
102
|
+
" return;",
|
|
103
|
+
" }",
|
|
104
|
+
" const message =",
|
|
105
|
+
" payload && payload.error && typeof payload.error.message === \"string\"",
|
|
106
|
+
" ? payload.error.message",
|
|
107
|
+
' : "We couldn\'t run that just now. Try again in a moment.";',
|
|
108
|
+
' postToApp({ protocol: PROTOCOL, type: "result", id: id, error: { message: message } });',
|
|
109
|
+
"}",
|
|
110
|
+
"",
|
|
111
|
+
'window.addEventListener("message", (event) => {',
|
|
112
|
+
" if (!messageFromApp(event)) {",
|
|
113
|
+
" return;",
|
|
114
|
+
" }",
|
|
115
|
+
" const data = event.data;",
|
|
116
|
+
' if (data.type === "ready") {',
|
|
117
|
+
' postToApp({ protocol: PROTOCOL, type: "hello" });',
|
|
118
|
+
" return;",
|
|
119
|
+
" }",
|
|
120
|
+
' if (data.type !== "call") {',
|
|
121
|
+
" return;",
|
|
122
|
+
" }",
|
|
123
|
+
" // Same shape rules as the production host: a bounded string id, a",
|
|
124
|
+
" // bounded string tool name, and params degrading to an empty object.",
|
|
125
|
+
" const id = data.id;",
|
|
126
|
+
" const tool = data.tool;",
|
|
127
|
+
' if (typeof id !== "string" || id.length === 0 || id.length > 64) {',
|
|
128
|
+
" return;",
|
|
129
|
+
" }",
|
|
130
|
+
' if (typeof tool !== "string" || tool.length === 0 || tool.length > 128) {',
|
|
131
|
+
" return;",
|
|
132
|
+
" }",
|
|
133
|
+
" const params =",
|
|
134
|
+
' typeof data.params === "object" && data.params !== null && !Array.isArray(data.params)',
|
|
135
|
+
" ? data.params",
|
|
136
|
+
" : {};",
|
|
137
|
+
" runToolCall(id, tool, params);",
|
|
138
|
+
"});",
|
|
139
|
+
"",
|
|
140
|
+
"// The CLI announces served-file changes; the app reloads itself on",
|
|
141
|
+
"// new-version, exactly as it would after a deploy.",
|
|
142
|
+
'const events = new EventSource("/events");',
|
|
143
|
+
'events.addEventListener("change", () => {',
|
|
144
|
+
' postToApp({ protocol: PROTOCOL, type: "new-version" });',
|
|
145
|
+
"});",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n");
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// The tool-call broker: the only place `driggsby dev` talks to Driggsby.
|
|
2
|
+
// It holds the app token in the CLI process (never in page HTML), calls the
|
|
3
|
+
// Driggsby MCP endpoint, and hands the host page a plain
|
|
4
|
+
// { ok, result | error } envelope. Bounds mirror the production host: a few
|
|
5
|
+
// calls in flight, a bounded queue, and a hard per-call timeout, so a
|
|
6
|
+
// buggy app can hammer its own laptop but never Driggsby.
|
|
7
|
+
import { GENERIC_TOOL_TROUBLE } from "./dev-servers.js";
|
|
8
|
+
export const MAX_IN_FLIGHT_CALLS = 4;
|
|
9
|
+
export const MAX_QUEUED_CALLS = 64;
|
|
10
|
+
export const TOOL_CALL_TIMEOUT_MS = 30_000;
|
|
11
|
+
// Server-supplied error text is untrusted; it reaches the page's console,
|
|
12
|
+
// so it gets a hard length cap like every other untrusted string we relay.
|
|
13
|
+
export const MAX_ERROR_MESSAGE_CHARS = 500;
|
|
14
|
+
export const SIGN_IN_AGAIN_MESSAGE = "Your sign-in on this machine isn't valid anymore. In this app's folder, run: npx driggsby@latest login — then reload this page.";
|
|
15
|
+
// Sent when an app doesn't provide its own reason. The Driggsby MCP
|
|
16
|
+
// endpoint requires a short reason on most read tools; a locally previewed
|
|
17
|
+
// dashboard has exactly one.
|
|
18
|
+
const DEFAULT_REASON = "Rendering this data in the developer's own Driggsby app preview.";
|
|
19
|
+
// One broker per `driggsby dev` run. runToolCall never rejects — every
|
|
20
|
+
// failure becomes an { ok: false } envelope with a person-readable message.
|
|
21
|
+
export class McpBroker {
|
|
22
|
+
options;
|
|
23
|
+
queue = [];
|
|
24
|
+
inFlight = 0;
|
|
25
|
+
nextRequestId = 1;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
async runToolCall(tool, argumentsObject) {
|
|
30
|
+
if (this.queue.length >= MAX_QUEUED_CALLS) {
|
|
31
|
+
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
32
|
+
}
|
|
33
|
+
return await new Promise((resolve) => {
|
|
34
|
+
this.queue.push({ tool, argumentsObject, resolve });
|
|
35
|
+
this.pump();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
pump() {
|
|
39
|
+
while (this.inFlight < MAX_IN_FLIGHT_CALLS) {
|
|
40
|
+
const call = this.queue.shift();
|
|
41
|
+
if (call === undefined) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.inFlight += 1;
|
|
45
|
+
void this.execute(call).finally(() => {
|
|
46
|
+
this.inFlight -= 1;
|
|
47
|
+
this.pump();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async execute(call) {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await this.callMcp(call.tool, call.argumentsObject);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
result = { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
58
|
+
}
|
|
59
|
+
call.resolve(result);
|
|
60
|
+
}
|
|
61
|
+
async callMcp(tool, argumentsObject) {
|
|
62
|
+
const toolArguments = { ...argumentsObject };
|
|
63
|
+
if (typeof toolArguments.reason !== "string" || toolArguments.reason.trim() === "") {
|
|
64
|
+
toolArguments.reason = DEFAULT_REASON;
|
|
65
|
+
}
|
|
66
|
+
const requestId = this.nextRequestId;
|
|
67
|
+
this.nextRequestId += 1;
|
|
68
|
+
const doFetch = this.options.fetchImplementation ?? fetch;
|
|
69
|
+
// Bare headers on purpose: the Driggsby endpoint refuses anything that
|
|
70
|
+
// looks like a browser call (an Origin or Sec-Fetch-Site header), and
|
|
71
|
+
// Node's fetch sends neither on its own.
|
|
72
|
+
const response = await doFetch(new URL("/mcp", this.options.baseUrl), {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
jsonrpc: "2.0",
|
|
80
|
+
id: requestId,
|
|
81
|
+
method: "tools/call",
|
|
82
|
+
params: { name: tool, arguments: toolArguments },
|
|
83
|
+
}),
|
|
84
|
+
// The bearer token must never travel to a redirect target, and tool
|
|
85
|
+
// results must never come from one either.
|
|
86
|
+
redirect: "error",
|
|
87
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? TOOL_CALL_TIMEOUT_MS),
|
|
88
|
+
});
|
|
89
|
+
if (response.status === 401) {
|
|
90
|
+
return { ok: false, error: { message: SIGN_IN_AGAIN_MESSAGE } };
|
|
91
|
+
}
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
94
|
+
}
|
|
95
|
+
let payload;
|
|
96
|
+
try {
|
|
97
|
+
payload = await response.json();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
101
|
+
}
|
|
102
|
+
return interpretJsonRpc(payload);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// The Driggsby MCP endpoint answers in JSON-RPC 2.0. Three shapes matter:
|
|
106
|
+
// a protocol-level error ({ error: { message } }), a tool-level refusal
|
|
107
|
+
// (result.isError true, message in content[0].text), and success
|
|
108
|
+
// (result.structuredContent is the data the app's watch callback gets).
|
|
109
|
+
function interpretJsonRpc(payload) {
|
|
110
|
+
if (typeof payload !== "object" || payload === null) {
|
|
111
|
+
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
112
|
+
}
|
|
113
|
+
const envelope = payload;
|
|
114
|
+
const error = envelope.error;
|
|
115
|
+
if (typeof error === "object" && error !== null) {
|
|
116
|
+
const message = error.message;
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: { message: typeof message === "string" ? capMessage(message) : GENERIC_TOOL_TROUBLE },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const result = envelope.result;
|
|
123
|
+
if (typeof result !== "object" || result === null) {
|
|
124
|
+
return { ok: false, error: { message: GENERIC_TOOL_TROUBLE } };
|
|
125
|
+
}
|
|
126
|
+
const resultRecord = result;
|
|
127
|
+
if (resultRecord.isError === true) {
|
|
128
|
+
return { ok: false, error: { message: toolRefusalMessage(resultRecord) } };
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, result: resultRecord.structuredContent ?? null };
|
|
131
|
+
}
|
|
132
|
+
function toolRefusalMessage(resultRecord) {
|
|
133
|
+
const content = resultRecord.content;
|
|
134
|
+
if (Array.isArray(content)) {
|
|
135
|
+
const first = content[0];
|
|
136
|
+
if (typeof first === "object" && first !== null) {
|
|
137
|
+
const text = first.text;
|
|
138
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
139
|
+
return capMessage(text);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return GENERIC_TOOL_TROUBLE;
|
|
144
|
+
}
|
|
145
|
+
function capMessage(message) {
|
|
146
|
+
return message.length > MAX_ERROR_MESSAGE_CHARS
|
|
147
|
+
? `${message.slice(0, MAX_ERROR_MESSAGE_CHARS)}…`
|
|
148
|
+
: message;
|
|
149
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The static file half of the local app origin: what `driggsby dev` serves
|
|
2
|
+
// from the project's serve folder. It serves what a deploy would publish —
|
|
3
|
+
// dotfiles at any depth, the top-level driggsby.json, node_modules, and
|
|
4
|
+
// symlinks are never served, just as the deploy walk never uploads them —
|
|
5
|
+
// so the local preview and the deployed app agree on what exists. The
|
|
6
|
+
// exclusions here match case-insensitively (stricter than the deploy walk):
|
|
7
|
+
// most local filesystems are case-insensitive, so /DRIGGSBY.JSON must not
|
|
8
|
+
// reach the file the exact-case check would have refused.
|
|
9
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
10
|
+
import { extname, join, normalize, relative, isAbsolute, sep } from "node:path";
|
|
11
|
+
const CONTENT_TYPES = {
|
|
12
|
+
".avif": "image/avif",
|
|
13
|
+
".css": "text/css; charset=utf-8",
|
|
14
|
+
".gif": "image/gif",
|
|
15
|
+
".html": "text/html; charset=utf-8",
|
|
16
|
+
".ico": "image/x-icon",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".js": "text/javascript; charset=utf-8",
|
|
20
|
+
".json": "application/json; charset=utf-8",
|
|
21
|
+
".map": "application/json; charset=utf-8",
|
|
22
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
25
|
+
".txt": "text/plain; charset=utf-8",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".woff": "font/woff",
|
|
28
|
+
".woff2": "font/woff2",
|
|
29
|
+
};
|
|
30
|
+
// Resolves a request path to a file under the serve folder, or null when the
|
|
31
|
+
// path is outside what dev serves (bad encoding, traversal, an excluded
|
|
32
|
+
// file, a symlink, or a missing file). "/" serves index.html, like the
|
|
33
|
+
// deployed app.
|
|
34
|
+
export async function serveStaticFile(serveDirectory, rawPath) {
|
|
35
|
+
const relativePath = safeRelativePath(rawPath);
|
|
36
|
+
if (relativePath === null) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const filePath = join(serveDirectory, relativePath);
|
|
40
|
+
if (!(await isRegularFileInside(serveDirectory, filePath))) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let body;
|
|
44
|
+
try {
|
|
45
|
+
body = await readFile(filePath);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const contentType = CONTENT_TYPES[extname(relativePath).toLowerCase()] ?? "application/octet-stream";
|
|
51
|
+
return { status: 200, contentType, body };
|
|
52
|
+
}
|
|
53
|
+
// Request path -> a safe relative file path, or null. Everything that could
|
|
54
|
+
// step outside the serve folder or reach an unpublished file fails closed.
|
|
55
|
+
// The exclusions mirror the deploy walk: dotfiles and node_modules at any
|
|
56
|
+
// depth, driggsby.json only at the top level (a nested one is an ordinary
|
|
57
|
+
// file there too) — refused in any casing, per the header note.
|
|
58
|
+
function safeRelativePath(rawPath) {
|
|
59
|
+
let decoded;
|
|
60
|
+
try {
|
|
61
|
+
decoded = decodeURIComponent(rawPath);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (decoded.includes("\0")) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const withoutLeadingSlash = decoded.replace(/^\/+/, "");
|
|
70
|
+
const relativePath = withoutLeadingSlash === "" ? "index.html" : withoutLeadingSlash;
|
|
71
|
+
const normalized = normalize(relativePath);
|
|
72
|
+
if (normalized.startsWith("..") || isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const segments = normalized.split(sep);
|
|
76
|
+
for (const segment of segments) {
|
|
77
|
+
const lowered = segment.toLowerCase();
|
|
78
|
+
if (segment === "" || lowered === ".." || lowered === "node_modules" || segment.startsWith(".")) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (normalized.toLowerCase() === "driggsby.json") {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
// The deploy walk skips symlinks, so dev refuses them too — locally that
|
|
88
|
+
// also stops a link from serving files outside the project. The realpath
|
|
89
|
+
// containment check backs the segment checks with the filesystem's own view.
|
|
90
|
+
async function isRegularFileInside(serveDirectory, filePath) {
|
|
91
|
+
try {
|
|
92
|
+
const stats = await lstat(filePath);
|
|
93
|
+
if (!stats.isFile()) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const realRoot = await realpath(serveDirectory);
|
|
97
|
+
const realFile = await realpath(filePath);
|
|
98
|
+
const relativePath = relative(realRoot, realFile);
|
|
99
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|