tina4-nodejs 3.12.9 → 3.13.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/CLAUDE.md +17 -17
- package/package.json +5 -1
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/index.ts +14 -1
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/server.ts +42 -3
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +14 -0
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* In production, call renderProductionError() instead.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
22
|
+
import { readFileSync, statSync } from "node:fs";
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
24
|
import { isTruthy } from "./dotenv.js";
|
|
25
25
|
|
|
@@ -111,8 +111,38 @@ function formatSourceBlock(filename: string, lineno: number): string {
|
|
|
111
111
|
+ rows + `</div>`;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Render one stack frame.
|
|
116
|
+
*
|
|
117
|
+
* When the file was modified AFTER `capturedAt`, append a peach
|
|
118
|
+
* "FILE MODIFIED" badge so a stale browser-cached overlay can't lie
|
|
119
|
+
* about what the source looks like now. The AI coder often rewrites
|
|
120
|
+
* files in place between page loads, leaving the overlay's source
|
|
121
|
+
* view showing different code than what raised the error.
|
|
122
|
+
*
|
|
123
|
+
* `capturedAt` is in seconds (Date.now() / 1000) for parity with
|
|
124
|
+
* Python's time.time().
|
|
125
|
+
*/
|
|
126
|
+
function formatFrame(frame: StackFrame, capturedAt = 0): string {
|
|
115
127
|
const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
|
|
128
|
+
let staleBadge = "";
|
|
129
|
+
if (capturedAt && frame.file) {
|
|
130
|
+
try {
|
|
131
|
+
const absPath = resolve(frame.file);
|
|
132
|
+
const mtime = statSync(absPath).mtimeMs / 1000;
|
|
133
|
+
if (mtime > capturedAt + 0.5) { // 0.5s margin for fs noise
|
|
134
|
+
const d = new Date(mtime * 1000);
|
|
135
|
+
const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:`
|
|
136
|
+
+ `${String(d.getUTCMinutes()).padStart(2, "0")}:`
|
|
137
|
+
+ `${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
|
138
|
+
staleBadge = ` <span style="background:${PEACH};color:${BG};padding:1px 8px;`
|
|
139
|
+
+ `border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">`
|
|
140
|
+
+ `FILE MODIFIED @ ${mtimeIso} UTC — source may not match what failed</span>`;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// best-effort — ignore missing files / permission errors
|
|
144
|
+
}
|
|
145
|
+
}
|
|
116
146
|
return `<div style="margin-bottom:16px;">`
|
|
117
147
|
+ `<div style="margin-bottom:4px;">`
|
|
118
148
|
+ `<span style="color:${BLUE};">${esc(frame.file)}</span>`
|
|
@@ -120,6 +150,7 @@ function formatFrame(frame: StackFrame): string {
|
|
|
120
150
|
+ `<span style="color:${YELLOW};">${frame.line}</span>`
|
|
121
151
|
+ `<span style="color:${SUBTEXT};"> in </span>`
|
|
122
152
|
+ `<span style="color:${GREEN};">${esc(frame.func)}</span>`
|
|
153
|
+
+ staleBadge
|
|
123
154
|
+ `</div>`
|
|
124
155
|
+ source
|
|
125
156
|
+ `</div>`;
|
|
@@ -153,14 +184,21 @@ function table(pairs: Array<[string, string]>): string {
|
|
|
153
184
|
* @returns Complete HTML page string.
|
|
154
185
|
*/
|
|
155
186
|
export function renderErrorOverlay(error: Error, request?: any): string {
|
|
187
|
+
// Stamp ONCE per render — every frame compares against this. Seconds-since-epoch
|
|
188
|
+
// matches Python's time.time() so frames stale by < 0.5s of fs noise don't trip.
|
|
189
|
+
const capturedAt = Date.now() / 1000;
|
|
156
190
|
const excType = error.constructor?.name ?? "Error";
|
|
157
191
|
const excMsg = error.message ?? String(error);
|
|
158
192
|
const frames = error.stack ? parseStack(error.stack) : [];
|
|
159
193
|
|
|
160
194
|
// ── Stack trace ──
|
|
195
|
+
// Each frame compares its source file's mtime to capturedAt and flags itself
|
|
196
|
+
// if the file has been modified since — protects against the "browser cached
|
|
197
|
+
// an old overlay, then the AI rewrote the file" confusion where displayed
|
|
198
|
+
// source no longer matches what actually raised the error.
|
|
161
199
|
let framesHtml = "";
|
|
162
200
|
for (const frame of frames) {
|
|
163
|
-
framesHtml += formatFrame(frame);
|
|
201
|
+
framesHtml += formatFrame(frame, capturedAt);
|
|
164
202
|
}
|
|
165
203
|
|
|
166
204
|
// ── Request info ──
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer feedback widget — Tier 4 port from tina4-python.
|
|
3
|
+
*
|
|
4
|
+
* End-users of a shipped Tina4 app give UX feedback via a floating bubble
|
|
5
|
+
* widget. Widget visibility + API are gated by TWO env flags:
|
|
6
|
+
*
|
|
7
|
+
* - TINA4_ENABLE_FEEDBACK master switch (explicit opt-in)
|
|
8
|
+
* - TINA4_FEEDBACK_WHITELIST comma-separated emails / user IDs
|
|
9
|
+
*
|
|
10
|
+
* Architecture (mirrors Python `tina4_python/dev_admin/__init__.py`
|
|
11
|
+
* lines 1440-1645):
|
|
12
|
+
*
|
|
13
|
+
* 1. Framework middleware injects <script src="/__feedback/widget.js">
|
|
14
|
+
* into HTML responses for whitelisted users only.
|
|
15
|
+
* 2. Widget POSTs to /__feedback/api/turn for each conversational turn.
|
|
16
|
+
* 3. That handler verifies whitelist + rate-limit, stamps the user
|
|
17
|
+
* identity server-side (client cannot fake `sender`), then forwards
|
|
18
|
+
* to the Rust agent's /feedback/intake.
|
|
19
|
+
*
|
|
20
|
+
* The widget is for END USERS of a shipped app — the /__dev paths get
|
|
21
|
+
* skipped so the dev admin's own chat bubble doesn't sit on top of the
|
|
22
|
+
* customer feedback bubble.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
26
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
import { authenticateRequest } from "./auth.js";
|
|
30
|
+
import { supervisorBaseUrl } from "./devAdmin.js";
|
|
31
|
+
import type { RouteHandler, Tina4Request } from "./types.js";
|
|
32
|
+
import type { Router } from "./router.js";
|
|
33
|
+
|
|
34
|
+
// ── Module-level rate-limit state ──────────────────────────────
|
|
35
|
+
// 5 turns/hour per identified user. Stored in-memory only; this is
|
|
36
|
+
// per-process — for multi-instance deployments a shared backend would
|
|
37
|
+
// be needed, but the python reference is the same shape.
|
|
38
|
+
const RATE_LIMIT_WINDOW_SEC = 3600;
|
|
39
|
+
const RATE_LIMIT_MAX = 5;
|
|
40
|
+
const _rateLimitHits = new Map<string, number[]>();
|
|
41
|
+
|
|
42
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Master switch — both this AND a non-empty whitelist must be set for
|
|
46
|
+
* the widget to render or the API to accept submissions. Mirrors
|
|
47
|
+
* Python's `_feedback_enabled()`.
|
|
48
|
+
*/
|
|
49
|
+
export function feedbackEnabled(): boolean {
|
|
50
|
+
const raw = (process.env.TINA4_ENABLE_FEEDBACK ?? "").trim().toLowerCase();
|
|
51
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Comma-separated emails / user IDs in env, lowercased + trimmed.
|
|
56
|
+
* Returns [] when the master switch is off so callers can short-circuit
|
|
57
|
+
* with a single check. Mirrors Python's `_feedback_whitelist()`.
|
|
58
|
+
*/
|
|
59
|
+
export function feedbackWhitelist(): string[] {
|
|
60
|
+
if (!feedbackEnabled()) return [];
|
|
61
|
+
const raw = (process.env.TINA4_FEEDBACK_WHITELIST ?? "").trim();
|
|
62
|
+
if (!raw) return [];
|
|
63
|
+
return raw
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((e) => e.trim().toLowerCase())
|
|
66
|
+
.filter((e) => e.length > 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort user identity from JWT/Bearer auth. Falls back to
|
|
71
|
+
* TINA4_FEEDBACK_DEV_USER (local dev convenience — lets the framework
|
|
72
|
+
* owner test the widget without a full auth setup). Mirrors Python's
|
|
73
|
+
* `_feedback_identify_user()`.
|
|
74
|
+
*/
|
|
75
|
+
export function feedbackIdentifyUser(request: Tina4Request): string | null {
|
|
76
|
+
try {
|
|
77
|
+
const payload = authenticateRequest(
|
|
78
|
+
(request.headers ?? {}) as Record<string, string | string[] | undefined>,
|
|
79
|
+
);
|
|
80
|
+
if (payload && typeof payload === "object") {
|
|
81
|
+
for (const key of ["email", "sub", "user_id"]) {
|
|
82
|
+
const v = (payload as Record<string, unknown>)[key];
|
|
83
|
+
if (v) return String(v).trim().toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore — fall through to dev override */
|
|
88
|
+
}
|
|
89
|
+
const dev = (process.env.TINA4_FEEDBACK_DEV_USER ?? "").trim();
|
|
90
|
+
if (dev) return dev.toLowerCase();
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns [allowed, userId]. Both halves are required — feature off when
|
|
96
|
+
* either is falsy. Mirrors Python's `_feedback_is_whitelisted()`.
|
|
97
|
+
*/
|
|
98
|
+
export function feedbackIsWhitelisted(
|
|
99
|
+
request: Tina4Request,
|
|
100
|
+
): [boolean, string | null] {
|
|
101
|
+
const wl = feedbackWhitelist();
|
|
102
|
+
if (wl.length === 0) return [false, null];
|
|
103
|
+
const user = feedbackIdentifyUser(request);
|
|
104
|
+
if (!user) return [false, null];
|
|
105
|
+
return [wl.includes(user), user];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 5 turns/hour per user, sliding window. Prunes old timestamps lazily on
|
|
110
|
+
* every call (no background task needed). Mirrors Python's
|
|
111
|
+
* `_feedback_rate_limit_ok()`.
|
|
112
|
+
*/
|
|
113
|
+
export function feedbackRateLimitOk(user: string): boolean {
|
|
114
|
+
const now = Date.now() / 1000;
|
|
115
|
+
const prior = _rateLimitHits.get(user) ?? [];
|
|
116
|
+
const fresh = prior.filter((t) => now - t < RATE_LIMIT_WINDOW_SEC);
|
|
117
|
+
if (fresh.length >= RATE_LIMIT_MAX) {
|
|
118
|
+
_rateLimitHits.set(user, fresh);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
fresh.push(now);
|
|
122
|
+
_rateLimitHits.set(user, fresh);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Test-only: clear rate-limit state between cases. Not part of public API. */
|
|
127
|
+
export function _resetFeedbackRateLimit(): void {
|
|
128
|
+
_rateLimitHits.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Insert the widget <script> into HTML for whitelisted users. Called
|
|
133
|
+
* from the response pipeline right before the body is flushed. No-op if:
|
|
134
|
+
* - request path starts with /__dev or /__feedback (developer
|
|
135
|
+
* pages have their own chat trigger)
|
|
136
|
+
* - master switch / whitelist not set
|
|
137
|
+
* - user not in whitelist
|
|
138
|
+
* - html lacks </body>
|
|
139
|
+
* Idempotent — looks for the `data-tina4-feedback` marker and bails.
|
|
140
|
+
* Mirrors Python's `inject_feedback_widget()`.
|
|
141
|
+
*/
|
|
142
|
+
export function injectFeedbackWidget(
|
|
143
|
+
request: Tina4Request,
|
|
144
|
+
html: string,
|
|
145
|
+
): string {
|
|
146
|
+
if (!html) return html;
|
|
147
|
+
const path = (request.path ?? "") || "";
|
|
148
|
+
if (path.startsWith("/__dev") || path.startsWith("/__feedback")) return html;
|
|
149
|
+
if (html.includes("data-tina4-feedback")) return html;
|
|
150
|
+
const [allowed] = feedbackIsWhitelisted(request);
|
|
151
|
+
if (!allowed) return html;
|
|
152
|
+
const lastBody = html.lastIndexOf("</body>");
|
|
153
|
+
if (lastBody < 0) return html;
|
|
154
|
+
const snippet =
|
|
155
|
+
'<script src="/__feedback/widget.js" data-tina4-feedback></script>';
|
|
156
|
+
return html.slice(0, lastBody) + snippet + html.slice(lastBody);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Route handlers ──────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* POST /__feedback/api/turn — proxy one conversational turn to the Rust
|
|
163
|
+
* agent's `/feedback/intake`. Server stamps `sender` from the verified
|
|
164
|
+
* identity so the client cannot inject who they are. Mirrors Python's
|
|
165
|
+
* `_api_feedback_turn()`.
|
|
166
|
+
*/
|
|
167
|
+
export const handleFeedbackTurn: RouteHandler = async (req, res) => {
|
|
168
|
+
const [allowed, user] = feedbackIsWhitelisted(req);
|
|
169
|
+
if (!allowed || !user) {
|
|
170
|
+
res.json({ error: "not authorised for feedback" }, 403);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!feedbackRateLimitOk(user)) {
|
|
174
|
+
res.json(
|
|
175
|
+
{
|
|
176
|
+
error: "rate limit exceeded",
|
|
177
|
+
hint: `max ${RATE_LIMIT_MAX} turns per hour`,
|
|
178
|
+
},
|
|
179
|
+
429,
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const body = (req as Tina4Request).body;
|
|
185
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
186
|
+
res.json({ error: "expected JSON body" }, 400);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Stamp sender server-side — client cannot override identity.
|
|
191
|
+
const forwardBody = { ...(body as Record<string, unknown>), sender: user };
|
|
192
|
+
const base = supervisorBaseUrl();
|
|
193
|
+
const target = `${base}/feedback/intake`;
|
|
194
|
+
|
|
195
|
+
const ctrl = new AbortController();
|
|
196
|
+
const timer = setTimeout(() => ctrl.abort(), 60_000);
|
|
197
|
+
|
|
198
|
+
let upstream: Response;
|
|
199
|
+
try {
|
|
200
|
+
upstream = await fetch(target, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify(forwardBody),
|
|
204
|
+
signal: ctrl.signal,
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
res.json(
|
|
209
|
+
{ error: "agent unreachable", detail: (e as Error).message },
|
|
210
|
+
502,
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
|
|
216
|
+
const raw = await upstream.text();
|
|
217
|
+
const status = upstream.status || 200;
|
|
218
|
+
try {
|
|
219
|
+
res.json(JSON.parse(raw), status);
|
|
220
|
+
} catch {
|
|
221
|
+
res.raw.writeHead(status, {
|
|
222
|
+
"Content-Type":
|
|
223
|
+
upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
|
|
224
|
+
});
|
|
225
|
+
res.raw.end(raw);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Widget bundle lives at packages/core/src/__feedback/widget.js so that
|
|
230
|
+
// it isn't auto-served by the static-file handler (which would skip the
|
|
231
|
+
// no-cache headers below).
|
|
232
|
+
const __feedbackDirname = dirname(fileURLToPath(import.meta.url));
|
|
233
|
+
const WIDGET_BUNDLE_PATH = resolve(__feedbackDirname, "__feedback", "widget.js");
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* GET /__feedback/widget.js — serve the widget bundle with no-cache
|
|
237
|
+
* headers so a broken bundle doesn't get stuck in browser caches.
|
|
238
|
+
* Mirrors Python's `_api_feedback_widget_js()`.
|
|
239
|
+
*/
|
|
240
|
+
export const handleFeedbackWidgetJs: RouteHandler = (_req, res) => {
|
|
241
|
+
let body: Buffer | string;
|
|
242
|
+
if (existsSync(WIDGET_BUNDLE_PATH)) {
|
|
243
|
+
body = readFileSync(WIDGET_BUNDLE_PATH);
|
|
244
|
+
} else {
|
|
245
|
+
body = "console.warn('tina4-feedback-widget bundle not built yet');";
|
|
246
|
+
}
|
|
247
|
+
res.raw.writeHead(200, {
|
|
248
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
249
|
+
"Cache-Control": "no-cache, must-revalidate",
|
|
250
|
+
Pragma: "no-cache",
|
|
251
|
+
});
|
|
252
|
+
res.raw.end(body);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Register the two feedback routes on a Router. Called from the dev
|
|
257
|
+
* admin setup so the routes only exist when the dev surface is
|
|
258
|
+
* enabled — production deployments without TINA4_DEBUG also skip them.
|
|
259
|
+
*/
|
|
260
|
+
export function registerFeedbackRoutes(router: Router): void {
|
|
261
|
+
router.addRoute({
|
|
262
|
+
method: "POST",
|
|
263
|
+
pattern: "/__feedback/api/turn",
|
|
264
|
+
handler: handleFeedbackTurn,
|
|
265
|
+
});
|
|
266
|
+
router.addRoute({
|
|
267
|
+
method: "GET",
|
|
268
|
+
pattern: "/__feedback/widget.js",
|
|
269
|
+
handler: handleFeedbackWidgetJs,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Re-export the bundle path so other tools (e.g. CLI builds) can find it.
|
|
274
|
+
export { WIDGET_BUNDLE_PATH };
|
|
275
|
+
// Silence unused-import lint where the helpers are imported but `join` isn't
|
|
276
|
+
// used in this file's current code path. (Kept available for future tweaks.)
|
|
277
|
+
void join;
|
|
@@ -71,7 +71,18 @@ export type { ResponseCacheConfig } from "./cache.js";
|
|
|
71
71
|
export { Api } from "./api.js";
|
|
72
72
|
export type { ApiResult } from "./api.js";
|
|
73
73
|
export { Events } from "./events.js";
|
|
74
|
-
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker } from "./devAdmin.js";
|
|
74
|
+
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl } from "./devAdmin.js";
|
|
75
|
+
export {
|
|
76
|
+
feedbackEnabled,
|
|
77
|
+
feedbackWhitelist,
|
|
78
|
+
feedbackIdentifyUser,
|
|
79
|
+
feedbackIsWhitelisted,
|
|
80
|
+
feedbackRateLimitOk,
|
|
81
|
+
injectFeedbackWidget,
|
|
82
|
+
handleFeedbackTurn,
|
|
83
|
+
handleFeedbackWidgetJs,
|
|
84
|
+
registerFeedbackRoutes,
|
|
85
|
+
} from "./feedback.js";
|
|
75
86
|
export { Messenger } from "./messenger.js";
|
|
76
87
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
77
88
|
export { DevMailbox, createMessenger } from "./devMailbox.js";
|
|
@@ -99,6 +110,8 @@ export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
|
|
|
99
110
|
export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
|
|
100
111
|
export { tests, assertEqual, assertRaises, assertTrue, assertFalse, runAll, reset } from "./testing.js";
|
|
101
112
|
export { TestClient, TestResponse } from "./testClient.js";
|
|
113
|
+
export { Tina4Test, AssertionError as Tina4AssertionError } from "./test.js";
|
|
114
|
+
export type { TestRunResults } from "./test.js";
|
|
102
115
|
export { Container, container } from "./container.js";
|
|
103
116
|
export { Validator } from "./validator.js";
|
|
104
117
|
export type { ValidationError } from "./validator.js";
|
|
@@ -390,6 +390,307 @@ console.log("\nInstance Registry");
|
|
|
390
390
|
assert("instances — cleared", McpServer._instances.length === 0);
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// ── Defensive Write Helpers (Tier 1 parity port from Python) ─
|
|
394
|
+
|
|
395
|
+
console.log("\nDefensive Write Helpers");
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Invoke an MCP tool and unwrap the JSON-RPC + tools/call envelope.
|
|
399
|
+
* The MCP `tools/call` response shape is:
|
|
400
|
+
* { jsonrpc, id, result: { content: [{ type: "text", text: "<json>" }] } }
|
|
401
|
+
* For string returns the inner text is the raw string; for object returns
|
|
402
|
+
* it is JSON-encoded — try to parse it back, fall through to the raw text.
|
|
403
|
+
*/
|
|
404
|
+
function callTool(server: McpServer, name: string, args: Record<string, unknown>): {
|
|
405
|
+
rpc: { jsonrpc?: string; id?: unknown; result?: unknown; error?: { code: number; message: string } };
|
|
406
|
+
result: Record<string, unknown> | string | null;
|
|
407
|
+
} {
|
|
408
|
+
const rpc = JSON.parse(server.handleMessage({
|
|
409
|
+
jsonrpc: "2.0", id: 1, method: "tools/call",
|
|
410
|
+
params: { name, arguments: args },
|
|
411
|
+
}));
|
|
412
|
+
let result: Record<string, unknown> | string | null = null;
|
|
413
|
+
const content = (rpc.result as { content?: Array<{ text?: string }> } | undefined)?.content;
|
|
414
|
+
if (content && content.length > 0 && typeof content[0].text === "string") {
|
|
415
|
+
const raw = content[0].text;
|
|
416
|
+
try {
|
|
417
|
+
result = JSON.parse(raw);
|
|
418
|
+
} catch {
|
|
419
|
+
result = raw;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { rpc, result };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// refuses prose paths in file_write
|
|
426
|
+
{
|
|
427
|
+
McpServer._instances = [];
|
|
428
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
429
|
+
const oldCwd = process.cwd();
|
|
430
|
+
process.chdir(tmpDir);
|
|
431
|
+
try {
|
|
432
|
+
const server = new McpServer("/test-prose", "Prose Test");
|
|
433
|
+
registerDevTools(server);
|
|
434
|
+
const { result } = callTool(server, "file_write", {
|
|
435
|
+
path: "The plan requires implementing a new feature for users.ts",
|
|
436
|
+
content: "x",
|
|
437
|
+
});
|
|
438
|
+
assert(
|
|
439
|
+
"refuses prose paths in file_write",
|
|
440
|
+
typeof result === "object" && result !== null && typeof (result as Record<string, unknown>).error === "string",
|
|
441
|
+
`Got: ${JSON.stringify(result)}`,
|
|
442
|
+
);
|
|
443
|
+
} finally {
|
|
444
|
+
process.chdir(oldCwd);
|
|
445
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// normalizes bare routes/ to src/routes/
|
|
450
|
+
{
|
|
451
|
+
McpServer._instances = [];
|
|
452
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
453
|
+
const oldCwd = process.cwd();
|
|
454
|
+
process.chdir(tmpDir);
|
|
455
|
+
try {
|
|
456
|
+
const server = new McpServer("/test-normalize", "Normalize Test");
|
|
457
|
+
registerDevTools(server);
|
|
458
|
+
const { result } = callTool(server, "file_write", {
|
|
459
|
+
path: "routes/foo.ts",
|
|
460
|
+
content: "export default async function (req, res) {}\n",
|
|
461
|
+
});
|
|
462
|
+
const landedAt = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
463
|
+
const written = (result as { written?: string } | null)?.written;
|
|
464
|
+
assert(
|
|
465
|
+
"normalizes bare routes/ to src/routes/ — file lands at src/routes/foo.ts",
|
|
466
|
+
fs.existsSync(landedAt) && (written ?? "").replace(/\\/g, "/") === "src/routes/foo.ts",
|
|
467
|
+
`Got: ${JSON.stringify(result)}; exists=${fs.existsSync(landedAt)}`,
|
|
468
|
+
);
|
|
469
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
470
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
471
|
+
assert(
|
|
472
|
+
"normalizes bare routes/ to src/routes/ — agent.log has path_normalized entry",
|
|
473
|
+
logContent.includes("write.path_normalized") && logContent.includes("routes/foo.ts"),
|
|
474
|
+
`Log content: ${logContent.slice(0, 300)}`,
|
|
475
|
+
);
|
|
476
|
+
} finally {
|
|
477
|
+
process.chdir(oldCwd);
|
|
478
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// backs up existing file before overwrite
|
|
483
|
+
{
|
|
484
|
+
McpServer._instances = [];
|
|
485
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
486
|
+
const oldCwd = process.cwd();
|
|
487
|
+
process.chdir(tmpDir);
|
|
488
|
+
try {
|
|
489
|
+
const server = new McpServer("/test-backup", "Backup Test");
|
|
490
|
+
registerDevTools(server);
|
|
491
|
+
const target = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
492
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
493
|
+
fs.writeFileSync(target, "original content\n", "utf-8");
|
|
494
|
+
|
|
495
|
+
const { result } = callTool(server, "file_write", {
|
|
496
|
+
path: "src/routes/foo.ts",
|
|
497
|
+
content: "new content that is reasonably similar in length to the old one\n",
|
|
498
|
+
});
|
|
499
|
+
const backup = (result as { backup?: string } | null)?.backup ?? "";
|
|
500
|
+
const backupDir = path.join(tmpDir, ".tina4", "backups");
|
|
501
|
+
const backups = fs.existsSync(backupDir) ? fs.readdirSync(backupDir) : [];
|
|
502
|
+
assert(
|
|
503
|
+
"backs up existing file before overwrite — backup in .tina4/backups/",
|
|
504
|
+
backups.length === 1 && backup.startsWith(".tina4/backups/"),
|
|
505
|
+
`Got: ${JSON.stringify(result)}; backups=${JSON.stringify(backups)}`,
|
|
506
|
+
);
|
|
507
|
+
assert(
|
|
508
|
+
"backs up existing file before overwrite — backup contains original",
|
|
509
|
+
backups.length === 1 &&
|
|
510
|
+
fs.readFileSync(path.join(backupDir, backups[0]), "utf-8") === "original content\n",
|
|
511
|
+
);
|
|
512
|
+
} finally {
|
|
513
|
+
process.chdir(oldCwd);
|
|
514
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// refuses suspicious truncation
|
|
519
|
+
{
|
|
520
|
+
McpServer._instances = [];
|
|
521
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
522
|
+
const oldCwd = process.cwd();
|
|
523
|
+
process.chdir(tmpDir);
|
|
524
|
+
try {
|
|
525
|
+
const server = new McpServer("/test-truncation", "Truncation Test");
|
|
526
|
+
registerDevTools(server);
|
|
527
|
+
const target = path.join(tmpDir, "src", "routes", "big.ts");
|
|
528
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
529
|
+
// 500-byte original
|
|
530
|
+
const original = "x".repeat(500);
|
|
531
|
+
fs.writeFileSync(target, original, "utf-8");
|
|
532
|
+
|
|
533
|
+
// Overwrite with 50 bytes — should be REFUSED (50/500 = 10% < 30%)
|
|
534
|
+
const { result } = callTool(server, "file_write", {
|
|
535
|
+
path: "src/routes/big.ts",
|
|
536
|
+
content: "y".repeat(50),
|
|
537
|
+
});
|
|
538
|
+
const r = result as { error?: string; refused?: boolean } | null;
|
|
539
|
+
assert(
|
|
540
|
+
"refuses suspicious truncation — returns error with refused flag",
|
|
541
|
+
r !== null && r.refused === true && typeof r.error === "string" && r.error.includes("REFUSED"),
|
|
542
|
+
`Got: ${JSON.stringify(result)}`,
|
|
543
|
+
);
|
|
544
|
+
const stillThere = fs.readFileSync(target, "utf-8");
|
|
545
|
+
assert(
|
|
546
|
+
"refuses suspicious truncation — original file intact",
|
|
547
|
+
stillThere === original,
|
|
548
|
+
`Original was modified: length=${stillThere.length}`,
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
process.chdir(oldCwd);
|
|
552
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// lets canonical src/ paths pass through (no rewrite)
|
|
557
|
+
{
|
|
558
|
+
McpServer._instances = [];
|
|
559
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
560
|
+
const oldCwd = process.cwd();
|
|
561
|
+
process.chdir(tmpDir);
|
|
562
|
+
try {
|
|
563
|
+
const server = new McpServer("/test-passthrough", "Passthrough Test");
|
|
564
|
+
registerDevTools(server);
|
|
565
|
+
const { result } = callTool(server, "file_write", {
|
|
566
|
+
path: "src/routes/foo.ts",
|
|
567
|
+
content: "export default async function (req, res) {}\n",
|
|
568
|
+
});
|
|
569
|
+
const landedAt = path.join(tmpDir, "src", "routes", "foo.ts");
|
|
570
|
+
const written = (result as { written?: string } | null)?.written;
|
|
571
|
+
assert(
|
|
572
|
+
"lets canonical src/ paths pass through — file lands at src/routes/foo.ts",
|
|
573
|
+
fs.existsSync(landedAt) && (written ?? "").replace(/\\/g, "/") === "src/routes/foo.ts",
|
|
574
|
+
`Got: ${JSON.stringify(result)}`,
|
|
575
|
+
);
|
|
576
|
+
// Path should NOT have been double-prefixed to src/src/routes/foo.ts
|
|
577
|
+
const doublyPrefixed = path.join(tmpDir, "src", "src", "routes", "foo.ts");
|
|
578
|
+
assert(
|
|
579
|
+
"lets canonical src/ paths pass through — no double-prefix",
|
|
580
|
+
!fs.existsSync(doublyPrefixed),
|
|
581
|
+
);
|
|
582
|
+
// agent.log should NOT contain a path_normalized entry for this write
|
|
583
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
584
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
585
|
+
assert(
|
|
586
|
+
"lets canonical src/ paths pass through — no path_normalized log entry",
|
|
587
|
+
!logContent.includes("write.path_normalized"),
|
|
588
|
+
`Log content: ${logContent.slice(0, 300)}`,
|
|
589
|
+
);
|
|
590
|
+
} finally {
|
|
591
|
+
process.chdir(oldCwd);
|
|
592
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// catches JS syntax errors via node --check
|
|
597
|
+
{
|
|
598
|
+
McpServer._instances = [];
|
|
599
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
600
|
+
const oldCwd = process.cwd();
|
|
601
|
+
process.chdir(tmpDir);
|
|
602
|
+
try {
|
|
603
|
+
const server = new McpServer("/test-verify-js", "Verify JS");
|
|
604
|
+
registerDevTools(server);
|
|
605
|
+
const { result } = callTool(server, "file_write", {
|
|
606
|
+
path: "src/routes/broken.js",
|
|
607
|
+
content: "const x = ;\n",
|
|
608
|
+
});
|
|
609
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
610
|
+
assert(
|
|
611
|
+
"catches JS syntax errors via node --check — result has import_error",
|
|
612
|
+
r !== null && typeof r.import_error === "string" && r.import_error.length > 0,
|
|
613
|
+
`Got: ${JSON.stringify(result)}`,
|
|
614
|
+
);
|
|
615
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
616
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
617
|
+
assert(
|
|
618
|
+
"catches JS syntax errors via node --check — agent.log has write.import_failed",
|
|
619
|
+
logContent.includes("write.import_failed") && logContent.includes("src/routes/broken.js"),
|
|
620
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
621
|
+
);
|
|
622
|
+
} finally {
|
|
623
|
+
process.chdir(oldCwd);
|
|
624
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// skips non-code files (no syntax check attempted)
|
|
629
|
+
{
|
|
630
|
+
McpServer._instances = [];
|
|
631
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
632
|
+
const oldCwd = process.cwd();
|
|
633
|
+
process.chdir(tmpDir);
|
|
634
|
+
try {
|
|
635
|
+
const server = new McpServer("/test-verify-skip-ext", "Verify Skip Ext");
|
|
636
|
+
registerDevTools(server);
|
|
637
|
+
// .twig content that would obviously fail any JS parser
|
|
638
|
+
const { result } = callTool(server, "file_write", {
|
|
639
|
+
path: "src/templates/page.twig",
|
|
640
|
+
content: "{% if not valid js %}<h1>Hi</h1>{% endif %}\n",
|
|
641
|
+
});
|
|
642
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
643
|
+
assert(
|
|
644
|
+
"skips non-code files — no import_error attached",
|
|
645
|
+
r !== null && r.import_error === undefined && typeof r.written === "string",
|
|
646
|
+
`Got: ${JSON.stringify(result)}`,
|
|
647
|
+
);
|
|
648
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
649
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
650
|
+
assert(
|
|
651
|
+
"skips non-code files — no write.import_failed in agent.log",
|
|
652
|
+
!logContent.includes("write.import_failed"),
|
|
653
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
654
|
+
);
|
|
655
|
+
} finally {
|
|
656
|
+
process.chdir(oldCwd);
|
|
657
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// skips files outside src/
|
|
662
|
+
{
|
|
663
|
+
McpServer._instances = [];
|
|
664
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tina4-mcp-test-"));
|
|
665
|
+
const oldCwd = process.cwd();
|
|
666
|
+
process.chdir(tmpDir);
|
|
667
|
+
try {
|
|
668
|
+
const server = new McpServer("/test-verify-skip-outside", "Verify Skip Outside");
|
|
669
|
+
registerDevTools(server);
|
|
670
|
+
// Broken JS placed under tests/ — must NOT be checked
|
|
671
|
+
const { result } = callTool(server, "file_write", {
|
|
672
|
+
path: "tests/foo.js",
|
|
673
|
+
content: "const x = ;\n",
|
|
674
|
+
});
|
|
675
|
+
const r = result as { written?: string; import_error?: string } | null;
|
|
676
|
+
assert(
|
|
677
|
+
"skips files outside src/ — no import_error attached",
|
|
678
|
+
r !== null && r.import_error === undefined && typeof r.written === "string",
|
|
679
|
+
`Got: ${JSON.stringify(result)}`,
|
|
680
|
+
);
|
|
681
|
+
const logPath = path.join(tmpDir, ".tina4", "agent.log");
|
|
682
|
+
const logContent = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : "";
|
|
683
|
+
assert(
|
|
684
|
+
"skips files outside src/ — no write.import_failed in agent.log",
|
|
685
|
+
!logContent.includes("write.import_failed"),
|
|
686
|
+
`Log content: ${logContent.slice(0, 400)}`,
|
|
687
|
+
);
|
|
688
|
+
} finally {
|
|
689
|
+
process.chdir(oldCwd);
|
|
690
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
393
694
|
// ── Summary ──────────────────────────────────────────────────
|
|
394
695
|
|
|
395
696
|
console.log(`\nMCP Tests: ${pass} passed, ${fail} failed`);
|