@harness-fe/mcp-server 3.4.1 → 4.0.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.d.ts +26 -5
- package/dist/bridge.js +43 -5
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +1 -0
- package/dist/identity.d.ts +90 -0
- package/dist/identity.js +123 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +91 -86
- package/dist/mcpHttp.js +7 -1
- package/dist/sessionRouter.d.ts +3 -0
- package/dist/store/JsonlStore.js +4 -0
- package/dist/store/types.d.ts +11 -0
- package/package.json +2 -2
- package/src/bridge.ts +57 -7
- package/src/daemon.ts +9 -0
- package/src/identity.test.ts +109 -0
- package/src/identity.ts +137 -0
- package/src/mcp.ts +114 -102
- package/src/mcpHttp.ts +7 -1
- package/src/sessionRouter.ts +3 -0
- package/src/store/JsonlStore.ts +4 -0
- package/src/store/identityTagging.test.ts +67 -0
- package/src/store/types.ts +11 -0
package/dist/mcp.js
CHANGED
|
@@ -9,6 +9,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
9
9
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
10
|
import { z } from 'zod';
|
|
11
11
|
import { COMMAND, PROTOCOL_VERSION, clickArgsSchema, evaluateArgsSchema, navigateArgsSchema, reloadArgsSchema, screenshotArgsSchema, scrollArgsSchema, setHtmlArgsSchema, setStyleArgsSchema, selectorSchema, typeArgsSchema, waitForArgsSchema, } from '@harness-fe/protocol';
|
|
12
|
+
import { canSee, identifyPrincipal } from './identity.js';
|
|
12
13
|
import { RemoteBridge } from './remoteBridge.js';
|
|
13
14
|
import { buildVisitorTimeline } from './visitorTimeline.js';
|
|
14
15
|
import { createReplayExport } from './replayCreate.js';
|
|
@@ -47,7 +48,7 @@ export function createMcpServer(bridge, options = {}) {
|
|
|
47
48
|
name: SERVER_NAME,
|
|
48
49
|
version: PROTOCOL_VERSION,
|
|
49
50
|
});
|
|
50
|
-
registerTools(server, bridge);
|
|
51
|
+
registerTools(server, bridge, options.auth);
|
|
51
52
|
// Experimental tools are on by default. They only get gated when the host
|
|
52
53
|
// supplies an env-var name to key off; see experimentalEnabled().
|
|
53
54
|
if (experimentalEnabled(options.experimentalEnvVar)) {
|
|
@@ -58,7 +59,7 @@ export function createMcpServer(bridge, options = {}) {
|
|
|
58
59
|
const leaderStore = bridge.store;
|
|
59
60
|
if (leaderStore != null) {
|
|
60
61
|
const memoryStore = bridge.getMemoryStore();
|
|
61
|
-
registerStoreTools(server, leaderStore, memoryStore, bridge);
|
|
62
|
+
registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
|
|
62
63
|
}
|
|
63
64
|
else if (bridge instanceof RemoteBridge) {
|
|
64
65
|
registerRemoteStoreTools(server, bridge);
|
|
@@ -87,7 +88,7 @@ function err(message) {
|
|
|
87
88
|
isError: true,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
|
-
function registerTools(server, bridge) {
|
|
91
|
+
function registerTools(server, bridge, auth) {
|
|
91
92
|
server.registerTool(COMMAND.PAGE_CLICK, {
|
|
92
93
|
description: 'Click on a DOM element resolved by the selector.',
|
|
93
94
|
inputSchema: {
|
|
@@ -452,83 +453,81 @@ function registerTools(server, bridge) {
|
|
|
452
453
|
return ok(out);
|
|
453
454
|
});
|
|
454
455
|
// ─── tasks.* tools (user annotations submitted from page) ─────────────
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
}
|
|
531
|
-
/* eslint-enable no-constant-condition */
|
|
456
|
+
const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
|
|
457
|
+
server.registerTool(COMMAND.TASKS_PENDING, {
|
|
458
|
+
description: 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
|
|
459
|
+
inputSchema: {
|
|
460
|
+
status: taskStatusEnum.optional(),
|
|
461
|
+
limit: z.number().int().positive().optional(),
|
|
462
|
+
},
|
|
463
|
+
}, async ({ status, limit }, extra) => {
|
|
464
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
465
|
+
const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
|
|
466
|
+
.filter((t) => canSee(principal, t.createdBy));
|
|
467
|
+
const summary = tasks.map((t) => ({
|
|
468
|
+
id: t.id,
|
|
469
|
+
status: t.status,
|
|
470
|
+
question: t.question,
|
|
471
|
+
selector: t.selector,
|
|
472
|
+
url: t.url,
|
|
473
|
+
tabId: t.tabId,
|
|
474
|
+
createdAt: t.createdAt,
|
|
475
|
+
claimedAt: t.claimedAt,
|
|
476
|
+
resolvedAt: t.resolvedAt,
|
|
477
|
+
note: t.note,
|
|
478
|
+
}));
|
|
479
|
+
return ok({ count: summary.length, tasks: summary });
|
|
480
|
+
});
|
|
481
|
+
server.registerTool(COMMAND.TASKS_CLAIM, {
|
|
482
|
+
description: 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
|
|
483
|
+
inputSchema: {
|
|
484
|
+
taskId: z.string(),
|
|
485
|
+
},
|
|
486
|
+
}, async ({ taskId }, extra) => {
|
|
487
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
488
|
+
const task = await bridge.claimTask(taskId, principal);
|
|
489
|
+
if (!task) {
|
|
490
|
+
throw new Error(`tasks.claim: no task with id "${taskId}"`);
|
|
491
|
+
}
|
|
492
|
+
return ok(task);
|
|
493
|
+
});
|
|
494
|
+
server.registerTool(COMMAND.TASKS_RESOLVE, {
|
|
495
|
+
description: 'Mark a task as resolved with an optional note. Use after addressing the user request.',
|
|
496
|
+
inputSchema: {
|
|
497
|
+
taskId: z.string(),
|
|
498
|
+
note: z.string().optional(),
|
|
499
|
+
},
|
|
500
|
+
}, async ({ taskId, note }, extra) => {
|
|
501
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
502
|
+
const task = await bridge.resolveTask(taskId, note, principal);
|
|
503
|
+
if (!task) {
|
|
504
|
+
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
505
|
+
}
|
|
506
|
+
return ok({ ok: true, task });
|
|
507
|
+
});
|
|
508
|
+
server.registerTool('tasks.get_attachment', {
|
|
509
|
+
description: 'Return a task screenshot attachment as a vision-ready image block. ' +
|
|
510
|
+
'Call after tasks.claim when the task summary includes an attachment pointer. ' +
|
|
511
|
+
'Compatible with Claude vision and GPT-4V.',
|
|
512
|
+
inputSchema: {
|
|
513
|
+
taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
|
|
514
|
+
attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
|
|
515
|
+
},
|
|
516
|
+
}, async ({ taskId, attachmentId }) => {
|
|
517
|
+
const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
|
|
518
|
+
if (!base64) {
|
|
519
|
+
throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
content: [
|
|
523
|
+
{
|
|
524
|
+
type: 'image',
|
|
525
|
+
mimeType: 'image/png',
|
|
526
|
+
data: base64,
|
|
527
|
+
},
|
|
528
|
+
],
|
|
529
|
+
};
|
|
530
|
+
});
|
|
532
531
|
}
|
|
533
532
|
// ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
|
|
534
533
|
/**
|
|
@@ -550,15 +549,18 @@ function registerExperimentalTools(server, bridge) {
|
|
|
550
549
|
void bridge;
|
|
551
550
|
}
|
|
552
551
|
// ─── Store tools (session history, timeline, memory) ──────────────────────────
|
|
553
|
-
function registerStoreTools(server, store, memoryStore, bridge) {
|
|
552
|
+
function registerStoreTools(server, store, memoryStore, bridge, auth) {
|
|
554
553
|
server.registerTool('session.list', {
|
|
555
554
|
description: 'List recent sessions for a project. Returns session IDs, start times, and status.',
|
|
556
555
|
inputSchema: {
|
|
557
556
|
projectId: z.string().describe('Project ID (package.json name)'),
|
|
558
557
|
limit: z.number().int().positive().default(10).optional(),
|
|
559
558
|
},
|
|
560
|
-
}, async ({ projectId, limit }) => {
|
|
561
|
-
const
|
|
559
|
+
}, async ({ projectId, limit }, extra) => {
|
|
560
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
561
|
+
const sessions = store
|
|
562
|
+
.listSessions({ projectId, limit: limit ?? 10 })
|
|
563
|
+
.filter((s) => canSee(principal, s.createdBy));
|
|
562
564
|
return ok(sessions);
|
|
563
565
|
});
|
|
564
566
|
server.registerTool('session.summary', {
|
|
@@ -627,11 +629,14 @@ function registerStoreTools(server, store, memoryStore, bridge) {
|
|
|
627
629
|
server.registerTool('project.sessions', {
|
|
628
630
|
description: 'List all projects with their most recent session info.',
|
|
629
631
|
inputSchema: {},
|
|
630
|
-
}, async () => {
|
|
631
|
-
const
|
|
632
|
+
}, async (_args, extra) => {
|
|
633
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
634
|
+
const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
|
|
632
635
|
const result = projects.map((p) => ({
|
|
633
636
|
...p,
|
|
634
|
-
recentSessions: store
|
|
637
|
+
recentSessions: store
|
|
638
|
+
.listSessions({ projectId: p.id, limit: 3 })
|
|
639
|
+
.filter((s) => canSee(principal, s.createdBy)),
|
|
635
640
|
}));
|
|
636
641
|
return ok(result);
|
|
637
642
|
});
|
package/dist/mcpHttp.js
CHANGED
|
@@ -21,7 +21,13 @@ export async function startMcpHttpServer(bridge, opts = {}) {
|
|
|
21
21
|
const eventStore = opts.eventStore === null
|
|
22
22
|
? undefined
|
|
23
23
|
: opts.eventStore ?? new MemoryEventStore();
|
|
24
|
-
|
|
24
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
25
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
26
|
+
// so stdio calls resolve to the local principal.
|
|
27
|
+
const server = createMcpServer(bridge, {
|
|
28
|
+
experimentalEnvVar: opts.experimentalEnvVar,
|
|
29
|
+
auth: bridge.getAuthOptions(),
|
|
30
|
+
});
|
|
25
31
|
const transport = new StreamableHTTPServerTransport({
|
|
26
32
|
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
27
33
|
eventStore,
|
package/dist/sessionRouter.d.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
* Caller can override via explicit tabId on every command.
|
|
9
9
|
*/
|
|
10
10
|
import type { PeerRole, TabInfo } from '@harness-fe/protocol';
|
|
11
|
+
import type { Principal } from './identity.js';
|
|
11
12
|
export interface PeerSession {
|
|
12
13
|
role: PeerRole;
|
|
13
14
|
projectId: string;
|
|
14
15
|
tabId?: string;
|
|
16
|
+
/** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
|
|
17
|
+
principal?: Principal;
|
|
15
18
|
/** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
|
|
16
19
|
sessionId?: string;
|
|
17
20
|
/** Runtime-client only: stable per-browser visitor identifier. */
|
package/dist/store/JsonlStore.js
CHANGED
|
@@ -401,6 +401,8 @@ export class JsonlStore {
|
|
|
401
401
|
...existing,
|
|
402
402
|
...meta,
|
|
403
403
|
id: sessionId,
|
|
404
|
+
// Write-once: first principal to open the session owns it.
|
|
405
|
+
createdBy: existing?.createdBy ?? meta.createdBy,
|
|
404
406
|
};
|
|
405
407
|
// Reset participants — we'll rebuild via dedup loop below
|
|
406
408
|
merged.participants = [];
|
|
@@ -552,6 +554,8 @@ export class JsonlStore {
|
|
|
552
554
|
id: projectId,
|
|
553
555
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
554
556
|
lastActiveAt: Date.now(),
|
|
557
|
+
// Write-once: the first principal to create the project owns it.
|
|
558
|
+
createdBy: existing?.createdBy ?? patch.createdBy,
|
|
555
559
|
};
|
|
556
560
|
enforceExtensionBudget(merged, `project ${projectId}`);
|
|
557
561
|
writeJson(metaPath, merged);
|
package/dist/store/types.d.ts
CHANGED
|
@@ -63,6 +63,12 @@ export interface ProjectMeta {
|
|
|
63
63
|
parentProjectId?: string;
|
|
64
64
|
displayName?: string;
|
|
65
65
|
tags?: string[];
|
|
66
|
+
/**
|
|
67
|
+
* Caller-identity tag (4.0 · P1): principal id that first created this
|
|
68
|
+
* project. Write-once (locked on creation like `createdAt`); informational
|
|
69
|
+
* in P1, the basis for `project → agent` routing/isolation in P3.
|
|
70
|
+
*/
|
|
71
|
+
createdBy?: string;
|
|
66
72
|
metadata?: Record<string, unknown>;
|
|
67
73
|
}
|
|
68
74
|
/**
|
|
@@ -140,6 +146,11 @@ export interface SessionMeta {
|
|
|
140
146
|
};
|
|
141
147
|
storageTruncated?: boolean;
|
|
142
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* Caller-identity tag (4.0 · P1): principal id of the connection that
|
|
151
|
+
* opened this session. Write-once. Informational in P1.
|
|
152
|
+
*/
|
|
153
|
+
createdBy?: string;
|
|
143
154
|
metadata?: Record<string, unknown>;
|
|
144
155
|
}
|
|
145
156
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harness-fe/mcp-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-next.2",
|
|
4
4
|
"description": "Unified MCP daemon: stdio MCP for AI agents + WS bridge for Vite plugin and runtime client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"ws": "^8.18.0",
|
|
40
40
|
"zod": "^4.4.3",
|
|
41
41
|
"@harness-fe/dashboard-ui": "0.2.0",
|
|
42
|
-
"@harness-fe/protocol": "
|
|
42
|
+
"@harness-fe/protocol": "4.0.0-next.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/ws": "^8.5.10",
|
package/src/bridge.ts
CHANGED
|
@@ -18,10 +18,12 @@ import { networkInterfaces } from 'node:os';
|
|
|
18
18
|
import {
|
|
19
19
|
DEFAULT_LOGIN_PATH,
|
|
20
20
|
handleLoginPost,
|
|
21
|
+
isAuthEnabled,
|
|
21
22
|
isAuthorized,
|
|
22
23
|
sendUnauthorized,
|
|
23
24
|
type AuthOptions,
|
|
24
25
|
} from './auth.js';
|
|
26
|
+
import { LOCAL_PRINCIPAL, resolvePrincipal, type Principal } from './identity.js';
|
|
25
27
|
import { join as joinPath } from 'node:path';
|
|
26
28
|
import { homedir } from 'node:os';
|
|
27
29
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
DEFAULT_WS_PORT,
|
|
30
32
|
EVENT_NAME,
|
|
31
33
|
PROTOCOL_VERSION,
|
|
34
|
+
type ConsentPolicy,
|
|
32
35
|
isLoopbackHost,
|
|
33
36
|
pageLoadPayloadSchema,
|
|
34
37
|
rrwebChunkPayloadSchema,
|
|
@@ -79,8 +82,8 @@ export interface IBridge {
|
|
|
79
82
|
): Promise<unknown>;
|
|
80
83
|
listTabs(): Promise<TabInfo[]>;
|
|
81
84
|
listTasks(filter?: { status?: TaskStatus | 'all'; limit?: number }): Promise<Task[]>;
|
|
82
|
-
claimTask(id: string): Promise<Task | undefined>;
|
|
83
|
-
resolveTask(id: string, note?: string): Promise<Task | undefined>;
|
|
85
|
+
claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
|
|
86
|
+
resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
|
|
84
87
|
getMemoryStore(): IMemoryStore;
|
|
85
88
|
/**
|
|
86
89
|
* Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
|
|
@@ -139,6 +142,13 @@ export interface BridgeOptions {
|
|
|
139
142
|
* token disables auth (only valid when bound to a loopback host).
|
|
140
143
|
*/
|
|
141
144
|
auth?: AuthOptions;
|
|
145
|
+
/**
|
|
146
|
+
* Browser-consent policy for control commands (4.0 · P2). When omitted it
|
|
147
|
+
* defaults to `session` while auth is enabled (non-loopback / exposed) and
|
|
148
|
+
* `off` on loopback — so solo dev keeps its zero-friction flow and any
|
|
149
|
+
* exposed daemon prompts the user before an agent drives the page.
|
|
150
|
+
*/
|
|
151
|
+
consent?: ConsentPolicy;
|
|
142
152
|
/**
|
|
143
153
|
* Override the host used when building outbound URLs (dashboard links,
|
|
144
154
|
* replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
|
|
@@ -221,8 +231,10 @@ export class Bridge implements IBridge {
|
|
|
221
231
|
private pending = new Map<string, PendingCommand>();
|
|
222
232
|
private eventListeners = new Set<EventListener>();
|
|
223
233
|
private tasks = new Map<string, Task>();
|
|
224
|
-
private opts: Required<Omit<BridgeOptions, 'store' | 'taskStore' | 'memoryStore' | 'autoPurge' | 'attachmentsDataDir' | 'auth' | 'publicHost' | 'dataDir' | 'label'>>;
|
|
234
|
+
private opts: Required<Omit<BridgeOptions, 'store' | 'taskStore' | 'memoryStore' | 'autoPurge' | 'attachmentsDataDir' | 'auth' | 'consent' | 'publicHost' | 'dataDir' | 'label'>>;
|
|
225
235
|
private auth: AuthOptions;
|
|
236
|
+
/** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
|
|
237
|
+
private readonly consentPolicy: ConsentPolicy;
|
|
226
238
|
private publicHostOverride: string | undefined;
|
|
227
239
|
private readonly attachDataDir: string;
|
|
228
240
|
private autoPurgeOpts: Required<NonNullable<BridgeOptions['autoPurge']>>;
|
|
@@ -233,6 +245,15 @@ export class Bridge implements IBridge {
|
|
|
233
245
|
* or sessionId (for runtime-client connections).
|
|
234
246
|
*/
|
|
235
247
|
private connToStoreId = new Map<string, string>();
|
|
248
|
+
/** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
|
|
249
|
+
private connToPrincipal = new Map<string, Principal>();
|
|
250
|
+
/**
|
|
251
|
+
* Identity attributed to MCP-driven writes (task claim/resolve). The MCP
|
|
252
|
+
* tool layer is a daemon-wide singleton today with no per-call caller
|
|
253
|
+
* context, so stdio/HTTP agents collapse to one principal. P4 (per-session
|
|
254
|
+
* MCP transport) replaces this with the real per-call principal.
|
|
255
|
+
*/
|
|
256
|
+
private readonly defaultPrincipal: Principal = LOCAL_PRINCIPAL;
|
|
236
257
|
/** Connections that already logged a "no store session" warning. */
|
|
237
258
|
private warnedNoSession = new Set<string>();
|
|
238
259
|
/**
|
|
@@ -273,6 +294,12 @@ export class Bridge implements IBridge {
|
|
|
273
294
|
host: opts.host ?? '127.0.0.1',
|
|
274
295
|
};
|
|
275
296
|
this.auth = opts.auth ?? {};
|
|
297
|
+
// Consent defaults track the auth boundary: exposed (auth on) ⇒ prompt
|
|
298
|
+
// once per session; loopback solo (auth off) ⇒ no prompts. Explicit
|
|
299
|
+
// opts.consent always wins.
|
|
300
|
+
this.consentPolicy = opts.consent ?? {
|
|
301
|
+
mode: isAuthEnabled(this.auth) ? 'session' : 'off',
|
|
302
|
+
};
|
|
276
303
|
this.publicHostOverride = opts.publicHost;
|
|
277
304
|
// Default auto-purge ON. CI / tests pass `enabled: false` (or set
|
|
278
305
|
// env HARNESS_FE_PURGE_DISABLED=1) to opt out.
|
|
@@ -440,7 +467,7 @@ export class Bridge implements IBridge {
|
|
|
440
467
|
});
|
|
441
468
|
|
|
442
469
|
const wss = new WebSocketServer({ noServer: true });
|
|
443
|
-
wss.on('connection', (ws) => this.onConnection(ws));
|
|
470
|
+
wss.on('connection', (ws, req: IncomingMessage) => this.onConnection(ws, req));
|
|
444
471
|
|
|
445
472
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
446
473
|
if (!isAuthorized(req, this.auth)) {
|
|
@@ -560,6 +587,11 @@ export class Bridge implements IBridge {
|
|
|
560
587
|
return this.auth.token;
|
|
561
588
|
}
|
|
562
589
|
|
|
590
|
+
/** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
|
|
591
|
+
getAuthOptions(): AuthOptions {
|
|
592
|
+
return this.auth;
|
|
593
|
+
}
|
|
594
|
+
|
|
563
595
|
/**
|
|
564
596
|
* Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
|
|
565
597
|
*
|
|
@@ -740,11 +772,15 @@ export class Bridge implements IBridge {
|
|
|
740
772
|
return filtered.slice(0, limit);
|
|
741
773
|
}
|
|
742
774
|
|
|
743
|
-
async claimTask(id: string): Promise<Task | undefined> {
|
|
775
|
+
async claimTask(id: string, principal?: Principal): Promise<Task | undefined> {
|
|
744
776
|
const task = this.tasks.get(id);
|
|
745
777
|
if (!task) return undefined;
|
|
746
778
|
task.status = 'claimed';
|
|
747
779
|
task.claimedAt = Date.now();
|
|
780
|
+
// Tag which agent picked it up (4.0 · P1/P4). The per-call principal
|
|
781
|
+
// (HTTP MCP, resolved from request headers) wins; stdio / no-caller
|
|
782
|
+
// falls back to the daemon's local principal.
|
|
783
|
+
task.agentId = (principal ?? this.defaultPrincipal).id;
|
|
748
784
|
this.persistTasks();
|
|
749
785
|
// Persist status change to store
|
|
750
786
|
this.persistTaskEvent(task, 'task:claim');
|
|
@@ -757,12 +793,13 @@ export class Bridge implements IBridge {
|
|
|
757
793
|
return this.readTaskAttachment(task.projectId, taskId, attachmentId);
|
|
758
794
|
}
|
|
759
795
|
|
|
760
|
-
async resolveTask(id: string, note?: string): Promise<Task | undefined> {
|
|
796
|
+
async resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined> {
|
|
761
797
|
const task = this.tasks.get(id);
|
|
762
798
|
if (!task) return undefined;
|
|
763
799
|
task.status = 'resolved';
|
|
764
800
|
task.resolvedAt = Date.now();
|
|
765
801
|
if (note !== undefined) task.note = note;
|
|
802
|
+
if (!task.agentId) task.agentId = (principal ?? this.defaultPrincipal).id;
|
|
766
803
|
this.persistTasks();
|
|
767
804
|
// Persist status change to store
|
|
768
805
|
this.persistTaskEvent(task, 'task:resolve');
|
|
@@ -1036,9 +1073,16 @@ export class Bridge implements IBridge {
|
|
|
1036
1073
|
return false;
|
|
1037
1074
|
}
|
|
1038
1075
|
|
|
1039
|
-
private onConnection(ws: WebSocket): void {
|
|
1076
|
+
private onConnection(ws: WebSocket, req?: IncomingMessage): void {
|
|
1040
1077
|
const connectionId = randomUUID();
|
|
1041
1078
|
this.sockets.set(connectionId, ws);
|
|
1079
|
+
// Resolve caller identity once at connection time (4.0 · P1). The
|
|
1080
|
+
// upgrade handler already enforced isAuthorized, so resolvePrincipal
|
|
1081
|
+
// won't reject here; fall back to LOCAL for the loopback / no-req path.
|
|
1082
|
+
this.connToPrincipal.set(
|
|
1083
|
+
connectionId,
|
|
1084
|
+
(req && resolvePrincipal(req, this.auth)) || LOCAL_PRINCIPAL,
|
|
1085
|
+
);
|
|
1042
1086
|
|
|
1043
1087
|
ws.on('message', (raw) => {
|
|
1044
1088
|
let parsed: unknown;
|
|
@@ -1096,6 +1140,7 @@ export class Bridge implements IBridge {
|
|
|
1096
1140
|
}
|
|
1097
1141
|
}
|
|
1098
1142
|
this.router.unregister(connectionId);
|
|
1143
|
+
this.connToPrincipal.delete(connectionId);
|
|
1099
1144
|
});
|
|
1100
1145
|
|
|
1101
1146
|
ws.on('error', () => {
|
|
@@ -1145,6 +1190,7 @@ export class Bridge implements IBridge {
|
|
|
1145
1190
|
// absent. The runtime-client branch below opens its own store
|
|
1146
1191
|
// session if one does not already exist for this project.
|
|
1147
1192
|
|
|
1193
|
+
const principal = this.connToPrincipal.get(connectionId) ?? LOCAL_PRINCIPAL;
|
|
1148
1194
|
const session = this.router.register({
|
|
1149
1195
|
role: frame.role,
|
|
1150
1196
|
projectId: frame.projectId,
|
|
@@ -1154,6 +1200,7 @@ export class Bridge implements IBridge {
|
|
|
1154
1200
|
userId: frame.userId,
|
|
1155
1201
|
connectionId,
|
|
1156
1202
|
page: frame.page,
|
|
1203
|
+
principal,
|
|
1157
1204
|
});
|
|
1158
1205
|
// Persist to store
|
|
1159
1206
|
if (this.store) {
|
|
@@ -1167,6 +1214,7 @@ export class Bridge implements IBridge {
|
|
|
1167
1214
|
this.store.upsertProject(frame.projectId, {
|
|
1168
1215
|
parentProjectId: frame.parentProjectId,
|
|
1169
1216
|
displayName: frame.displayName,
|
|
1217
|
+
createdBy: principal.id,
|
|
1170
1218
|
});
|
|
1171
1219
|
} catch (err) {
|
|
1172
1220
|
// Cycle detection or other validation failure —
|
|
@@ -1244,6 +1292,7 @@ export class Bridge implements IBridge {
|
|
|
1244
1292
|
referrer: undefined,
|
|
1245
1293
|
userAgent: frame.page?.userAgent,
|
|
1246
1294
|
participants,
|
|
1295
|
+
createdBy: principal.id,
|
|
1247
1296
|
});
|
|
1248
1297
|
this.connToStoreId.set(connectionId, sessionId);
|
|
1249
1298
|
this.notifyDashboard({
|
|
@@ -1286,6 +1335,7 @@ export class Bridge implements IBridge {
|
|
|
1286
1335
|
id: frame.id,
|
|
1287
1336
|
tabId: session.tabId,
|
|
1288
1337
|
serverVersion: PROTOCOL_VERSION,
|
|
1338
|
+
consent: this.consentPolicy,
|
|
1289
1339
|
};
|
|
1290
1340
|
ws.send(JSON.stringify(ack));
|
|
1291
1341
|
// One concise line per accepted peer. Visibility for "is the
|
package/src/daemon.ts
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import type { IncomingMessage } from 'node:http';
|
|
27
27
|
|
|
28
|
+
import type { ConsentPolicy } from '@harness-fe/protocol';
|
|
29
|
+
|
|
28
30
|
import { Bridge } from './bridge.js';
|
|
29
31
|
import { startMcpHttpServer } from './mcpHttp.js';
|
|
30
32
|
import type { EventStore, IStore } from './store/types.js';
|
|
@@ -60,6 +62,12 @@ export interface DaemonOptions {
|
|
|
60
62
|
* flows through here. Mutually exclusive with `authorize`.
|
|
61
63
|
*/
|
|
62
64
|
token?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Browser-consent policy for control commands (4.0 · P2). Omit to track the
|
|
67
|
+
* auth boundary automatically: `session` when auth is on (exposed daemon),
|
|
68
|
+
* `off` on loopback solo dev. Pass `{ mode }` to force a policy.
|
|
69
|
+
*/
|
|
70
|
+
consent?: ConsentPolicy;
|
|
63
71
|
/**
|
|
64
72
|
* IStore implementation. Omit for the default JSONL store at `dataDir`.
|
|
65
73
|
* Pass `null` to disable session/event persistence entirely.
|
|
@@ -150,6 +158,7 @@ export function createDaemon(opts: DaemonOptions = {}): DaemonHandle {
|
|
|
150
158
|
dataDir: opts.dataDir,
|
|
151
159
|
label: opts.label,
|
|
152
160
|
auth,
|
|
161
|
+
consent: opts.consent,
|
|
153
162
|
});
|
|
154
163
|
|
|
155
164
|
let mcpHandle: Awaited<ReturnType<typeof startMcpHttpServer>> | undefined;
|