@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.4
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/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +65 -19
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +88 -18
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +67 -23
- package/src/mcpHttp.test.ts +52 -3
- package/src/mcpHttp.ts +102 -23
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
package/dist/dashboardSpa.js
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP handler that serves the React SPA built by `@harness-fe/dashboard-ui`.
|
|
3
|
-
*
|
|
4
|
-
* Routing rules (after the `isAuthorized` middleware in bridge.ts):
|
|
5
|
-
* - GET / → 302 to /dashboard/?token=<preserved> (legacy root)
|
|
6
|
-
* - GET /sessions/:id → 302 to /dashboard/sessions/:id?token=… (legacy bookmarks)
|
|
7
|
-
* - GET /dashboard → 302 to /dashboard/?token=<preserved>
|
|
8
|
-
* - GET /dashboard/ → serve index.html (SPA shell)
|
|
9
|
-
* - GET /dashboard/<asset.ext> → serve that file from dist/ (if it exists)
|
|
10
|
-
* - GET /dashboard/<other-path> → serve index.html (SPA client-side routing)
|
|
11
|
-
*
|
|
12
|
-
* The dist directory is resolved at module load via `require.resolve()` on
|
|
13
|
-
* the dashboard-ui package — same trick `replayViewer.ts` uses for
|
|
14
|
-
* rrweb-player. No copy step needed; pnpm workspace symlinks just work in
|
|
15
|
-
* dev, and `pnpm deploy` bundles the dist into the published tarball.
|
|
16
|
-
*/
|
|
17
|
-
import { createRequire } from 'node:module';
|
|
18
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
-
import { dirname, extname, join, resolve as resolvePath } from 'node:path';
|
|
20
|
-
import { DEFAULT_COOKIE_NAME } from './auth.js';
|
|
21
|
-
const require = createRequire(import.meta.url);
|
|
22
|
-
/**
|
|
23
|
-
* If a request arrived with `?token=` AND no harness_fe_token cookie yet,
|
|
24
|
-
* stamp the cookie and 302 back to the same path without the query.
|
|
25
|
-
*
|
|
26
|
-
* Why this matters: the SPA bundle is loaded by the browser as
|
|
27
|
-
* `<script src="/dashboard/assets/index-XXX.js">` — relative paths without
|
|
28
|
-
* the token query — which would 401 against the upstream auth middleware.
|
|
29
|
-
* Setting the cookie on the first HTML request means every subsequent
|
|
30
|
-
* same-origin fetch (assets, /api/*, WebSocket upgrade) is automatically
|
|
31
|
-
* authenticated, and the visible URL stays clean.
|
|
32
|
-
*/
|
|
33
|
-
function maybeHandleTokenHandoff(req, res, url) {
|
|
34
|
-
const tokenInQuery = url.searchParams.get('token');
|
|
35
|
-
if (!tokenInQuery)
|
|
36
|
-
return false;
|
|
37
|
-
// If the cookie's already set with the same value, nothing to do —
|
|
38
|
-
// pass through (the browser will follow links without ?token=).
|
|
39
|
-
const cookies = req.headers.cookie ?? '';
|
|
40
|
-
if (cookies.includes(`${DEFAULT_COOKIE_NAME}=`))
|
|
41
|
-
return false;
|
|
42
|
-
// Strip token from the URL and 302 back to the canonical path so the
|
|
43
|
-
// browser bookmarks / shares clean URLs. The cookie carries auth from
|
|
44
|
-
// here on. Also normalize `/dashboard` → `/dashboard/` in the same hop
|
|
45
|
-
// so we don't waste a second redirect chasing the trailing slash.
|
|
46
|
-
url.searchParams.delete('token');
|
|
47
|
-
const canonicalPath = url.pathname === '/dashboard' ? '/dashboard/' : url.pathname;
|
|
48
|
-
const remaining = url.searchParams.toString();
|
|
49
|
-
const clean = `${canonicalPath}${remaining ? '?' + remaining : ''}`;
|
|
50
|
-
const cookie = `${DEFAULT_COOKIE_NAME}=${encodeURIComponent(tokenInQuery)}; ` +
|
|
51
|
-
// Path=/ so /api/*, /replay/*, /dashboard/* all see it.
|
|
52
|
-
// SameSite=Lax keeps the cookie on cross-tab nav.
|
|
53
|
-
// No HttpOnly: the SPA's WS code may want to surface the token in
|
|
54
|
-
// a Bearer header for tools that don't share cookies (rare —
|
|
55
|
-
// browsers attach cookies to WS, so this is just defense in depth).
|
|
56
|
-
// 30-day Max-Age matches the /__auth login flow.
|
|
57
|
-
`Path=/; SameSite=Lax; Max-Age=2592000`;
|
|
58
|
-
res.statusCode = 302;
|
|
59
|
-
res.setHeader('set-cookie', cookie);
|
|
60
|
-
res.setHeader('location', clean);
|
|
61
|
-
res.setHeader('cache-control', 'no-store');
|
|
62
|
-
res.end();
|
|
63
|
-
return true;
|
|
64
|
-
}
|
|
65
|
-
function resolveDashboardDist() {
|
|
66
|
-
try {
|
|
67
|
-
const pkgPath = require.resolve('@harness-fe/dashboard-ui/package.json');
|
|
68
|
-
const dist = join(dirname(pkgPath), 'dist');
|
|
69
|
-
return existsSync(join(dist, 'index.html')) ? dist : undefined;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const CONTENT_TYPES = {
|
|
76
|
-
'.html': 'text/html; charset=utf-8',
|
|
77
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
78
|
-
'.mjs': 'application/javascript; charset=utf-8',
|
|
79
|
-
'.css': 'text/css; charset=utf-8',
|
|
80
|
-
'.json': 'application/json; charset=utf-8',
|
|
81
|
-
'.svg': 'image/svg+xml',
|
|
82
|
-
'.png': 'image/png',
|
|
83
|
-
'.jpg': 'image/jpeg',
|
|
84
|
-
'.jpeg': 'image/jpeg',
|
|
85
|
-
'.gif': 'image/gif',
|
|
86
|
-
'.webp': 'image/webp',
|
|
87
|
-
'.ico': 'image/x-icon',
|
|
88
|
-
'.woff': 'font/woff',
|
|
89
|
-
'.woff2': 'font/woff2',
|
|
90
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
91
|
-
'.map': 'application/json; charset=utf-8',
|
|
92
|
-
};
|
|
93
|
-
function contentTypeFor(filePath) {
|
|
94
|
-
return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
95
|
-
}
|
|
96
|
-
export function createDashboardSpaHandler() {
|
|
97
|
-
const dist = resolveDashboardDist();
|
|
98
|
-
return (req, res) => {
|
|
99
|
-
if (!req.url)
|
|
100
|
-
return false;
|
|
101
|
-
const url = new URL(req.url, 'http://localhost');
|
|
102
|
-
const path = url.pathname;
|
|
103
|
-
const method = req.method ?? 'GET';
|
|
104
|
-
if (method !== 'GET' && method !== 'HEAD')
|
|
105
|
-
return false;
|
|
106
|
-
// Legacy paths from the old server-rendered dashboard — redirect into
|
|
107
|
-
// the SPA preserving the token query so the user stays authenticated.
|
|
108
|
-
if (path === '/' || path === '/index.html') {
|
|
109
|
-
res.statusCode = 302;
|
|
110
|
-
res.setHeader('location', `/dashboard/${url.search ?? ''}`);
|
|
111
|
-
res.end();
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
const legacySession = path.match(/^\/sessions\/([^/]+)$/);
|
|
115
|
-
if (legacySession) {
|
|
116
|
-
const sid = legacySession[1];
|
|
117
|
-
res.statusCode = 302;
|
|
118
|
-
res.setHeader('location', `/dashboard/sessions/${sid}${url.search ?? ''}`);
|
|
119
|
-
res.end();
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
if (!path.startsWith('/dashboard'))
|
|
123
|
-
return false;
|
|
124
|
-
// First-load token handoff: ?token=… → cookie + redirect.
|
|
125
|
-
// Skip for static asset paths; setting the cookie there would still
|
|
126
|
-
// work but the redirect would break the script tag's request chain.
|
|
127
|
-
if (!path.startsWith('/dashboard/assets/') && !path.startsWith('/dashboard/static/')) {
|
|
128
|
-
if (maybeHandleTokenHandoff(req, res, url))
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
// If dashboard-ui isn't installed (someone using an older deploy
|
|
132
|
-
// or running tests in isolation), fall through with a friendly
|
|
133
|
-
// 503 rather than 404 — clearer signal than silent miss.
|
|
134
|
-
if (!dist) {
|
|
135
|
-
res.statusCode = 503;
|
|
136
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
137
|
-
res.end('Dashboard UI is not bundled with this build.');
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
// /dashboard with no trailing slash → redirect, preserving token.
|
|
141
|
-
if (path === '/dashboard') {
|
|
142
|
-
const search = url.search ?? '';
|
|
143
|
-
res.statusCode = 302;
|
|
144
|
-
res.setHeader('location', `/dashboard/${search}`);
|
|
145
|
-
res.end();
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
|
-
// Strip the `/dashboard/` prefix to get the relative path inside dist.
|
|
149
|
-
const rel = path.slice('/dashboard/'.length) || 'index.html';
|
|
150
|
-
// Path traversal defense: resolve against dist and require the
|
|
151
|
-
// result is still inside dist. Without this, a crafted URL like
|
|
152
|
-
// `/dashboard/../../etc/passwd` would escape.
|
|
153
|
-
const candidate = resolvePath(dist, rel);
|
|
154
|
-
if (!candidate.startsWith(dist + (dist.endsWith('/') ? '' : '/')) && candidate !== dist) {
|
|
155
|
-
res.statusCode = 403;
|
|
156
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
157
|
-
res.end('Forbidden');
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
// Serve the file if it exists; otherwise fall back to index.html
|
|
161
|
-
// so the SPA's client-side router can take over (this is how /sessions/:id
|
|
162
|
-
// works without server config).
|
|
163
|
-
const target = existsSync(candidate) ? candidate : join(dist, 'index.html');
|
|
164
|
-
try {
|
|
165
|
-
const body = readFileSync(target);
|
|
166
|
-
res.statusCode = 200;
|
|
167
|
-
res.setHeader('content-type', contentTypeFor(target));
|
|
168
|
-
// Hashed assets are immutable; HTML shells should never cache.
|
|
169
|
-
const isHashed = /\/assets\/.+-[A-Za-z0-9_-]{6,}\.[a-z0-9]+$/.test(target);
|
|
170
|
-
res.setHeader('cache-control', isHashed ? 'public, max-age=31536000, immutable' : 'no-store');
|
|
171
|
-
res.end(body);
|
|
172
|
-
}
|
|
173
|
-
catch (err) {
|
|
174
|
-
res.statusCode = 500;
|
|
175
|
-
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
176
|
-
res.end(`dashboard read error: ${err instanceof Error ? err.message : String(err)}`);
|
|
177
|
-
}
|
|
178
|
-
return true;
|
|
179
|
-
};
|
|
180
|
-
}
|
package/dist/dashboardUrl.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compose dashboard URLs the user (or agent) should hit.
|
|
3
|
-
*
|
|
4
|
-
* Carries the configured auth token in the query string so the browser
|
|
5
|
-
* lands pre-authenticated. On loopback hosts with no token configured,
|
|
6
|
-
* the URL is left bare.
|
|
7
|
-
*/
|
|
8
|
-
import type { IBridge } from './bridge.js';
|
|
9
|
-
export interface DashboardUrlOptions {
|
|
10
|
-
/** When provided, deep-link to a specific session detail page. */
|
|
11
|
-
sessionId?: string;
|
|
12
|
-
}
|
|
13
|
-
export declare function buildDashboardUrl(bridge: IBridge, opts?: DashboardUrlOptions): string | undefined;
|
package/dist/dashboardUrl.js
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compose dashboard URLs the user (or agent) should hit.
|
|
3
|
-
*
|
|
4
|
-
* Carries the configured auth token in the query string so the browser
|
|
5
|
-
* lands pre-authenticated. On loopback hosts with no token configured,
|
|
6
|
-
* the URL is left bare.
|
|
7
|
-
*/
|
|
8
|
-
export function buildDashboardUrl(bridge, opts = {}) {
|
|
9
|
-
const base = bridge.getViewerBaseUrl();
|
|
10
|
-
if (!base)
|
|
11
|
-
return undefined;
|
|
12
|
-
const path = opts.sessionId
|
|
13
|
-
? `/dashboard/sessions/${encodeURIComponent(opts.sessionId)}`
|
|
14
|
-
: '/dashboard/';
|
|
15
|
-
const token = bridge.getAuthToken();
|
|
16
|
-
const qs = token ? `?token=${encodeURIComponent(token)}` : '';
|
|
17
|
-
return `${base}${path}${qs}`;
|
|
18
|
-
}
|
package/dist/eventsHandler.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP POST /events handler — stateless alternative to the WebSocket path.
|
|
3
|
-
*
|
|
4
|
-
* Accepts a JSON body matching `httpBatchSchema`:
|
|
5
|
-
* { hello: { role: 'node-runtime', projectId, sessionId, ... },
|
|
6
|
-
* events: [ { id, name, ts, payload, ... }, ... ] }
|
|
7
|
-
*
|
|
8
|
-
* Each POST is treated as a one-shot hello+events sequence:
|
|
9
|
-
* 1. Register (or re-register) the peer via the same bridge internals.
|
|
10
|
-
* 2. Persist every event to the right session timeline.
|
|
11
|
-
* 3. Respond 204 No Content.
|
|
12
|
-
*
|
|
13
|
-
* Also handles:
|
|
14
|
-
* GET /events/ping → 200 { ok: true, version }
|
|
15
|
-
* OPTIONS /events → 204 CORS preflight
|
|
16
|
-
*/
|
|
17
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
18
|
-
import type { Bridge } from './bridge.js';
|
|
19
|
-
/**
|
|
20
|
-
* Returns a handler that matches /events/* routes and returns `true` when it
|
|
21
|
-
* handled the request (so the bridge can short-circuit its 404 fallback).
|
|
22
|
-
*/
|
|
23
|
-
export declare function createEventsHandler(bridge: Bridge): (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
24
|
-
export type EventsHandler = ReturnType<typeof createEventsHandler>;
|
package/dist/eventsHandler.js
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP POST /events handler — stateless alternative to the WebSocket path.
|
|
3
|
-
*
|
|
4
|
-
* Accepts a JSON body matching `httpBatchSchema`:
|
|
5
|
-
* { hello: { role: 'node-runtime', projectId, sessionId, ... },
|
|
6
|
-
* events: [ { id, name, ts, payload, ... }, ... ] }
|
|
7
|
-
*
|
|
8
|
-
* Each POST is treated as a one-shot hello+events sequence:
|
|
9
|
-
* 1. Register (or re-register) the peer via the same bridge internals.
|
|
10
|
-
* 2. Persist every event to the right session timeline.
|
|
11
|
-
* 3. Respond 204 No Content.
|
|
12
|
-
*
|
|
13
|
-
* Also handles:
|
|
14
|
-
* GET /events/ping → 200 { ok: true, version }
|
|
15
|
-
* OPTIONS /events → 204 CORS preflight
|
|
16
|
-
*/
|
|
17
|
-
import { PROTOCOL_VERSION, httpBatchSchema } from '@harness-fe/protocol';
|
|
18
|
-
// ─── CORS helpers ────────────────────────────────────────────────────────────
|
|
19
|
-
function isLoopback(req) {
|
|
20
|
-
const host = req.headers['host'] ?? '';
|
|
21
|
-
// Match 127.0.0.1:* and localhost:*
|
|
22
|
-
return /^(127\.0\.0\.1|localhost)(:\d+)?$/.test(host);
|
|
23
|
-
}
|
|
24
|
-
function setCorsHeaders(res) {
|
|
25
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
26
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
27
|
-
res.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
28
|
-
}
|
|
29
|
-
// ─── Body reader ─────────────────────────────────────────────────────────────
|
|
30
|
-
function readBody(req) {
|
|
31
|
-
return new Promise((resolve, reject) => {
|
|
32
|
-
const chunks = [];
|
|
33
|
-
req.on('data', (chunk) => chunks.push(chunk));
|
|
34
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
35
|
-
req.on('error', reject);
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
// ─── Factory ─────────────────────────────────────────────────────────────────
|
|
39
|
-
/**
|
|
40
|
-
* Returns a handler that matches /events/* routes and returns `true` when it
|
|
41
|
-
* handled the request (so the bridge can short-circuit its 404 fallback).
|
|
42
|
-
*/
|
|
43
|
-
export function createEventsHandler(bridge) {
|
|
44
|
-
return async (req, res) => {
|
|
45
|
-
const url = req.url ?? '';
|
|
46
|
-
const method = req.method ?? 'GET';
|
|
47
|
-
// Only intercept /events and /events/ping
|
|
48
|
-
if (url !== '/events' && url !== '/events/ping')
|
|
49
|
-
return false;
|
|
50
|
-
// CORS: only emit headers when request comes from loopback
|
|
51
|
-
if (isLoopback(req)) {
|
|
52
|
-
setCorsHeaders(res);
|
|
53
|
-
}
|
|
54
|
-
// Preflight for both routes
|
|
55
|
-
if (method === 'OPTIONS') {
|
|
56
|
-
res.statusCode = 204;
|
|
57
|
-
res.end();
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
// GET /events/ping
|
|
61
|
-
if (url === '/events/ping' && method === 'GET') {
|
|
62
|
-
res.statusCode = 200;
|
|
63
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
64
|
-
res.end(JSON.stringify({ ok: true, version: PROTOCOL_VERSION }));
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
// POST /events
|
|
68
|
-
if (url === '/events' && method === 'POST') {
|
|
69
|
-
let rawBody;
|
|
70
|
-
try {
|
|
71
|
-
rawBody = await readBody(req);
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
res.statusCode = 400;
|
|
75
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
76
|
-
res.end(JSON.stringify({ error: 'failed to read request body' }));
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
let parsed;
|
|
80
|
-
try {
|
|
81
|
-
parsed = JSON.parse(rawBody);
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
res.statusCode = 400;
|
|
85
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
86
|
-
res.end(JSON.stringify({ error: 'invalid JSON' }));
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
const validated = httpBatchSchema.safeParse(parsed);
|
|
90
|
-
if (!validated.success) {
|
|
91
|
-
res.statusCode = 400;
|
|
92
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
93
|
-
res.end(JSON.stringify({ error: validated.error.message }));
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
const { hello, events } = validated.data;
|
|
97
|
-
try {
|
|
98
|
-
bridge.handleHttpBatch(hello, events);
|
|
99
|
-
}
|
|
100
|
-
catch (err) {
|
|
101
|
-
res.statusCode = 500;
|
|
102
|
-
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
103
|
-
res.end(JSON.stringify({
|
|
104
|
-
error: err instanceof Error ? err.message : 'internal error',
|
|
105
|
-
}));
|
|
106
|
-
return true;
|
|
107
|
-
}
|
|
108
|
-
res.statusCode = 204;
|
|
109
|
-
res.end();
|
|
110
|
-
return true;
|
|
111
|
-
}
|
|
112
|
-
return false;
|
|
113
|
-
};
|
|
114
|
-
}
|
package/dist/identity.d.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller identity (4.0 · P1) — turns the auth boundary from a plain
|
|
3
|
-
* allow/deny into "allow/deny + *who*".
|
|
4
|
-
*
|
|
5
|
-
* Phase 1 scope: this module only *establishes* and *carries* a Principal,
|
|
6
|
-
* and the bridge *tags* writes with it (createdBy). It deliberately does NOT
|
|
7
|
-
* filter reads by owner — that's P3 (tenant isolation). Keeping the two apart
|
|
8
|
-
* means identity plumbing lands with zero behaviour change: loopback solo dev
|
|
9
|
-
* stays a single implicit `local` principal, and an authorized caller sees
|
|
10
|
-
* everything exactly as before.
|
|
11
|
-
*
|
|
12
|
-
* Why a separate module from auth.ts: `isAuthorized` answers a boolean and is
|
|
13
|
-
* consumed on the hot path of every HTTP route / WS upgrade. `resolvePrincipal`
|
|
14
|
-
* is the richer, additive view layered on top — it reuses the same primitives
|
|
15
|
-
* (isAuthEnabled / extractToken / verifyToken) so the two can never disagree on
|
|
16
|
-
* who is allowed in.
|
|
17
|
-
*/
|
|
18
|
-
import type { IncomingMessage } from 'node:http';
|
|
19
|
-
import { type AuthOptions } from './auth.js';
|
|
20
|
-
export type PrincipalKind = 'local' | 'token' | 'host';
|
|
21
|
-
export interface Principal {
|
|
22
|
-
/** Stable id for this caller. Loopback / stdio solo dev → `local`. */
|
|
23
|
-
id: string;
|
|
24
|
-
/** How the identity was established. */
|
|
25
|
-
kind: PrincipalKind;
|
|
26
|
-
/** Optional human-readable label (for dashboards / audit). */
|
|
27
|
-
displayName?: string;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* The implicit single principal for loopback and stdio solo dev. The daemon
|
|
31
|
-
* trusts everything that can reach the loopback socket, so there is one
|
|
32
|
-
* caller and it owns everything — exactly today's behaviour, now named.
|
|
33
|
-
*/
|
|
34
|
-
export declare const LOCAL_PRINCIPAL: Principal;
|
|
35
|
-
/**
|
|
36
|
-
* Principal for the custom-`authorize` path. Hosts that embed the daemon own
|
|
37
|
-
* their own user model; until `authorize` can return a richer identity
|
|
38
|
-
* (future work), an authorized host caller maps to this single principal.
|
|
39
|
-
*/
|
|
40
|
-
export declare const HOST_PRINCIPAL: Principal;
|
|
41
|
-
/**
|
|
42
|
-
* Derive a stable principal id from a bearer token. One token = one principal
|
|
43
|
-
* in 4.0's trusted-team model. We hash so the raw secret never becomes an id
|
|
44
|
-
* that could leak into stored `createdBy` tags or audit logs.
|
|
45
|
-
*/
|
|
46
|
-
export declare function tokenPrincipalId(token: string): string;
|
|
47
|
-
/**
|
|
48
|
-
* Resolve the caller behind a request.
|
|
49
|
-
*
|
|
50
|
-
* Returns `null` when auth is enabled and the request is NOT authorized — this
|
|
51
|
-
* mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
|
|
52
|
-
* principal as "reject" without a second auth check.
|
|
53
|
-
*
|
|
54
|
-
* - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
|
|
55
|
-
* - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
|
|
56
|
-
* - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
|
|
57
|
-
*/
|
|
58
|
-
export declare function resolvePrincipal(req: IncomingMessage, opts: AuthOptions): Principal | null;
|
|
59
|
-
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
60
|
-
/**
|
|
61
|
-
* Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
|
|
62
|
-
*
|
|
63
|
-
* The HTTP MCP request has already cleared the bridge's auth wrapper by the
|
|
64
|
-
* time a tool runs, so this only needs to *name* the caller — never to
|
|
65
|
-
* re-check them. Pass the per-request headers from the MCP SDK's
|
|
66
|
-
* `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
|
|
67
|
-
* (the daemon trusts its local stdio agent).
|
|
68
|
-
*
|
|
69
|
-
* - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
|
|
70
|
-
* - custom authorize → {@link HOST_PRINCIPAL}
|
|
71
|
-
* - token mode → token principal from the Authorization header (LOCAL if absent)
|
|
72
|
-
*/
|
|
73
|
-
export declare function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal;
|
|
74
|
-
/**
|
|
75
|
-
* Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
|
|
76
|
-
* may see a record tagged with `createdBy`.
|
|
77
|
-
*
|
|
78
|
-
* - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
|
|
79
|
-
* solo dev's behaviour completely unchanged.
|
|
80
|
-
* - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
|
|
81
|
-
* records the daemon never tagged) → visible to everyone (backward compat).
|
|
82
|
-
* - otherwise → visible only to the principal that created it.
|
|
83
|
-
*
|
|
84
|
-
* Note: in the current single-token / loopback reality the data creator
|
|
85
|
-
* (plugin / runtime client) and the querying agent share one principal, so
|
|
86
|
-
* this is exact. A full `project → agent` binding (creator ≠ consumer, once
|
|
87
|
-
* P6 splits write/read scopes) is deferred to P6.
|
|
88
|
-
*/
|
|
89
|
-
export declare function canSee(principal: Principal, createdBy: string | null | undefined): boolean;
|
|
90
|
-
export {};
|
package/dist/identity.js
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller identity (4.0 · P1) — turns the auth boundary from a plain
|
|
3
|
-
* allow/deny into "allow/deny + *who*".
|
|
4
|
-
*
|
|
5
|
-
* Phase 1 scope: this module only *establishes* and *carries* a Principal,
|
|
6
|
-
* and the bridge *tags* writes with it (createdBy). It deliberately does NOT
|
|
7
|
-
* filter reads by owner — that's P3 (tenant isolation). Keeping the two apart
|
|
8
|
-
* means identity plumbing lands with zero behaviour change: loopback solo dev
|
|
9
|
-
* stays a single implicit `local` principal, and an authorized caller sees
|
|
10
|
-
* everything exactly as before.
|
|
11
|
-
*
|
|
12
|
-
* Why a separate module from auth.ts: `isAuthorized` answers a boolean and is
|
|
13
|
-
* consumed on the hot path of every HTTP route / WS upgrade. `resolvePrincipal`
|
|
14
|
-
* is the richer, additive view layered on top — it reuses the same primitives
|
|
15
|
-
* (isAuthEnabled / extractToken / verifyToken) so the two can never disagree on
|
|
16
|
-
* who is allowed in.
|
|
17
|
-
*/
|
|
18
|
-
import { createHash } from 'node:crypto';
|
|
19
|
-
import { extractToken, isAuthEnabled, verifyToken } from './auth.js';
|
|
20
|
-
/**
|
|
21
|
-
* The implicit single principal for loopback and stdio solo dev. The daemon
|
|
22
|
-
* trusts everything that can reach the loopback socket, so there is one
|
|
23
|
-
* caller and it owns everything — exactly today's behaviour, now named.
|
|
24
|
-
*/
|
|
25
|
-
export const LOCAL_PRINCIPAL = Object.freeze({
|
|
26
|
-
id: 'local',
|
|
27
|
-
kind: 'local',
|
|
28
|
-
displayName: 'local',
|
|
29
|
-
});
|
|
30
|
-
/**
|
|
31
|
-
* Principal for the custom-`authorize` path. Hosts that embed the daemon own
|
|
32
|
-
* their own user model; until `authorize` can return a richer identity
|
|
33
|
-
* (future work), an authorized host caller maps to this single principal.
|
|
34
|
-
*/
|
|
35
|
-
export const HOST_PRINCIPAL = Object.freeze({
|
|
36
|
-
id: 'host',
|
|
37
|
-
kind: 'host',
|
|
38
|
-
displayName: 'host',
|
|
39
|
-
});
|
|
40
|
-
/**
|
|
41
|
-
* Derive a stable principal id from a bearer token. One token = one principal
|
|
42
|
-
* in 4.0's trusted-team model. We hash so the raw secret never becomes an id
|
|
43
|
-
* that could leak into stored `createdBy` tags or audit logs.
|
|
44
|
-
*/
|
|
45
|
-
export function tokenPrincipalId(token) {
|
|
46
|
-
return `token:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Resolve the caller behind a request.
|
|
50
|
-
*
|
|
51
|
-
* Returns `null` when auth is enabled and the request is NOT authorized — this
|
|
52
|
-
* mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
|
|
53
|
-
* principal as "reject" without a second auth check.
|
|
54
|
-
*
|
|
55
|
-
* - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
|
|
56
|
-
* - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
|
|
57
|
-
* - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
|
|
58
|
-
*/
|
|
59
|
-
export function resolvePrincipal(req, opts) {
|
|
60
|
-
if (!isAuthEnabled(opts))
|
|
61
|
-
return LOCAL_PRINCIPAL;
|
|
62
|
-
if (opts.authorize)
|
|
63
|
-
return opts.authorize(req) ? HOST_PRINCIPAL : null;
|
|
64
|
-
const token = extractToken(req, opts);
|
|
65
|
-
if (!verifyToken(token, opts.token))
|
|
66
|
-
return null;
|
|
67
|
-
return { id: tokenPrincipalId(token), kind: 'token' };
|
|
68
|
-
}
|
|
69
|
-
function bearerFromHeaders(headers) {
|
|
70
|
-
const raw = headers['authorization'] ?? headers['Authorization'];
|
|
71
|
-
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
72
|
-
if (typeof v === 'string' && v.startsWith('Bearer ')) {
|
|
73
|
-
const t = v.slice(7).trim();
|
|
74
|
-
if (t)
|
|
75
|
-
return t;
|
|
76
|
-
}
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
|
|
81
|
-
*
|
|
82
|
-
* The HTTP MCP request has already cleared the bridge's auth wrapper by the
|
|
83
|
-
* time a tool runs, so this only needs to *name* the caller — never to
|
|
84
|
-
* re-check them. Pass the per-request headers from the MCP SDK's
|
|
85
|
-
* `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
|
|
86
|
-
* (the daemon trusts its local stdio agent).
|
|
87
|
-
*
|
|
88
|
-
* - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
|
|
89
|
-
* - custom authorize → {@link HOST_PRINCIPAL}
|
|
90
|
-
* - token mode → token principal from the Authorization header (LOCAL if absent)
|
|
91
|
-
*/
|
|
92
|
-
export function identifyPrincipal(headers, opts) {
|
|
93
|
-
if (!isAuthEnabled(opts))
|
|
94
|
-
return LOCAL_PRINCIPAL;
|
|
95
|
-
if (opts.authorize)
|
|
96
|
-
return HOST_PRINCIPAL;
|
|
97
|
-
if (!headers)
|
|
98
|
-
return LOCAL_PRINCIPAL;
|
|
99
|
-
const token = bearerFromHeaders(headers);
|
|
100
|
-
return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
|
|
104
|
-
* may see a record tagged with `createdBy`.
|
|
105
|
-
*
|
|
106
|
-
* - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
|
|
107
|
-
* solo dev's behaviour completely unchanged.
|
|
108
|
-
* - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
|
|
109
|
-
* records the daemon never tagged) → visible to everyone (backward compat).
|
|
110
|
-
* - otherwise → visible only to the principal that created it.
|
|
111
|
-
*
|
|
112
|
-
* Note: in the current single-token / loopback reality the data creator
|
|
113
|
-
* (plugin / runtime client) and the querying agent share one principal, so
|
|
114
|
-
* this is exact. A full `project → agent` binding (creator ≠ consumer, once
|
|
115
|
-
* P6 splits write/read scopes) is deferred to P6.
|
|
116
|
-
*/
|
|
117
|
-
export function canSee(principal, createdBy) {
|
|
118
|
-
if (principal.kind === 'local')
|
|
119
|
-
return true;
|
|
120
|
-
if (createdBy == null)
|
|
121
|
-
return true;
|
|
122
|
-
return createdBy === principal.id;
|
|
123
|
-
}
|
package/dist/openBrowser.d.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-platform "open this URL in the user's default browser" — a tiny
|
|
3
|
-
* wrapper around the OS-native command.
|
|
4
|
-
*
|
|
5
|
-
* Detection rules:
|
|
6
|
-
* - darwin → `open <url>`
|
|
7
|
-
* - linux → `xdg-open <url>`
|
|
8
|
-
* - win32 → `cmd /c start "" <url>` (the empty title is required, otherwise `start` treats the URL as a title)
|
|
9
|
-
*
|
|
10
|
-
* Escape hatches:
|
|
11
|
-
* - `HARNESS_FE_HEADLESS=1` short-circuits and returns `false` without
|
|
12
|
-
* spawning anything — useful when the daemon runs in Docker / CI /
|
|
13
|
-
* remote host where there's no GUI to open
|
|
14
|
-
* - any other platform returns `false`
|
|
15
|
-
*
|
|
16
|
-
* The spawned process is detached and stdio'd to ignore so we don't
|
|
17
|
-
* accidentally tie its lifetime to ours.
|
|
18
|
-
*/
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
|
-
export interface OpenBrowserOptions {
|
|
21
|
-
/** Inject an alternate `process.platform` value, for tests. */
|
|
22
|
-
platformOverride?: NodeJS.Platform;
|
|
23
|
-
/** Inject the env lookup, for tests. */
|
|
24
|
-
envOverride?: Record<string, string | undefined>;
|
|
25
|
-
/** Inject the spawn function, for tests. */
|
|
26
|
-
spawnOverride?: typeof spawn;
|
|
27
|
-
}
|
|
28
|
-
export interface OpenBrowserResult {
|
|
29
|
-
opened: boolean;
|
|
30
|
-
/** Set when `opened` is false to explain why. */
|
|
31
|
-
reason?: string;
|
|
32
|
-
}
|
|
33
|
-
export declare function openBrowser(url: string, opts?: OpenBrowserOptions): OpenBrowserResult;
|
package/dist/openBrowser.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cross-platform "open this URL in the user's default browser" — a tiny
|
|
3
|
-
* wrapper around the OS-native command.
|
|
4
|
-
*
|
|
5
|
-
* Detection rules:
|
|
6
|
-
* - darwin → `open <url>`
|
|
7
|
-
* - linux → `xdg-open <url>`
|
|
8
|
-
* - win32 → `cmd /c start "" <url>` (the empty title is required, otherwise `start` treats the URL as a title)
|
|
9
|
-
*
|
|
10
|
-
* Escape hatches:
|
|
11
|
-
* - `HARNESS_FE_HEADLESS=1` short-circuits and returns `false` without
|
|
12
|
-
* spawning anything — useful when the daemon runs in Docker / CI /
|
|
13
|
-
* remote host where there's no GUI to open
|
|
14
|
-
* - any other platform returns `false`
|
|
15
|
-
*
|
|
16
|
-
* The spawned process is detached and stdio'd to ignore so we don't
|
|
17
|
-
* accidentally tie its lifetime to ours.
|
|
18
|
-
*/
|
|
19
|
-
import { spawn } from 'node:child_process';
|
|
20
|
-
export function openBrowser(url, opts = {}) {
|
|
21
|
-
const env = opts.envOverride ?? process.env;
|
|
22
|
-
if (env.HARNESS_FE_HEADLESS === '1') {
|
|
23
|
-
return { opened: false, reason: 'HARNESS_FE_HEADLESS=1' };
|
|
24
|
-
}
|
|
25
|
-
const platform = opts.platformOverride ?? process.platform;
|
|
26
|
-
const spawnFn = opts.spawnOverride ?? spawn;
|
|
27
|
-
let cmd;
|
|
28
|
-
let args;
|
|
29
|
-
switch (platform) {
|
|
30
|
-
case 'darwin':
|
|
31
|
-
cmd = 'open';
|
|
32
|
-
args = [url];
|
|
33
|
-
break;
|
|
34
|
-
case 'linux':
|
|
35
|
-
cmd = 'xdg-open';
|
|
36
|
-
args = [url];
|
|
37
|
-
break;
|
|
38
|
-
case 'win32':
|
|
39
|
-
// `start` is a cmd builtin, not a standalone exe. The first
|
|
40
|
-
// empty-string arg is the window title — required, because
|
|
41
|
-
// otherwise `start "https://…"` treats the URL as the title
|
|
42
|
-
// and never opens anything.
|
|
43
|
-
cmd = 'cmd';
|
|
44
|
-
args = ['/c', 'start', '', url];
|
|
45
|
-
break;
|
|
46
|
-
default:
|
|
47
|
-
return { opened: false, reason: `unsupported platform: ${platform}` };
|
|
48
|
-
}
|
|
49
|
-
try {
|
|
50
|
-
const child = spawnFn(cmd, args, {
|
|
51
|
-
detached: true,
|
|
52
|
-
stdio: 'ignore',
|
|
53
|
-
});
|
|
54
|
-
child.unref();
|
|
55
|
-
return { opened: true };
|
|
56
|
-
}
|
|
57
|
-
catch (err) {
|
|
58
|
-
return {
|
|
59
|
-
opened: false,
|
|
60
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
}
|