@harness-fe/mcp-server 3.4.0 → 4.0.0-next.1
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 +74 -0
- package/dist/identity.js +101 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.js +9 -6
- 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 +3 -3
- package/src/bridge.ts +57 -7
- package/src/daemon.ts +9 -0
- package/src/identity.test.ts +86 -0
- package/src/identity.ts +116 -0
- package/src/mcp.ts +16 -6
- 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/bridge.d.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
14
14
|
import { type AuthOptions } from './auth.js';
|
|
15
|
-
import { type
|
|
15
|
+
import { type Principal } from './identity.js';
|
|
16
|
+
import { type ConsentPolicy, type EventFrame, type HttpBatch, type TabInfo, type Task, type TaskStatus } from '@harness-fe/protocol';
|
|
16
17
|
import { SessionRouter, type PeerSession } from './sessionRouter.js';
|
|
17
18
|
import { type IStore, type ITaskStore, type IMemoryStore, type RetentionPolicy } from './store/index.js';
|
|
18
19
|
/**
|
|
@@ -29,8 +30,8 @@ export interface IBridge {
|
|
|
29
30
|
status?: TaskStatus | 'all';
|
|
30
31
|
limit?: number;
|
|
31
32
|
}): Promise<Task[]>;
|
|
32
|
-
claimTask(id: string): Promise<Task | undefined>;
|
|
33
|
-
resolveTask(id: string, note?: string): Promise<Task | undefined>;
|
|
33
|
+
claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
|
|
34
|
+
resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
|
|
34
35
|
getMemoryStore(): IMemoryStore;
|
|
35
36
|
/**
|
|
36
37
|
* Base URL (e.g. http://127.0.0.1:47729) where the replay viewer is reachable.
|
|
@@ -75,6 +76,13 @@ export interface BridgeOptions {
|
|
|
75
76
|
* token disables auth (only valid when bound to a loopback host).
|
|
76
77
|
*/
|
|
77
78
|
auth?: AuthOptions;
|
|
79
|
+
/**
|
|
80
|
+
* Browser-consent policy for control commands (4.0 · P2). When omitted it
|
|
81
|
+
* defaults to `session` while auth is enabled (non-loopback / exposed) and
|
|
82
|
+
* `off` on loopback — so solo dev keeps its zero-friction flow and any
|
|
83
|
+
* exposed daemon prompts the user before an agent drives the page.
|
|
84
|
+
*/
|
|
85
|
+
consent?: ConsentPolicy;
|
|
78
86
|
/**
|
|
79
87
|
* Override the host used when building outbound URLs (dashboard links,
|
|
80
88
|
* replay viewer URLs). When omitted and `host` is `0.0.0.0` / `::`, the
|
|
@@ -157,6 +165,8 @@ export declare class Bridge implements IBridge {
|
|
|
157
165
|
private tasks;
|
|
158
166
|
private opts;
|
|
159
167
|
private auth;
|
|
168
|
+
/** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
|
|
169
|
+
private readonly consentPolicy;
|
|
160
170
|
private publicHostOverride;
|
|
161
171
|
private readonly attachDataDir;
|
|
162
172
|
private autoPurgeOpts;
|
|
@@ -167,6 +177,15 @@ export declare class Bridge implements IBridge {
|
|
|
167
177
|
* or sessionId (for runtime-client connections).
|
|
168
178
|
*/
|
|
169
179
|
private connToStoreId;
|
|
180
|
+
/** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
|
|
181
|
+
private connToPrincipal;
|
|
182
|
+
/**
|
|
183
|
+
* Identity attributed to MCP-driven writes (task claim/resolve). The MCP
|
|
184
|
+
* tool layer is a daemon-wide singleton today with no per-call caller
|
|
185
|
+
* context, so stdio/HTTP agents collapse to one principal. P4 (per-session
|
|
186
|
+
* MCP transport) replaces this with the real per-call principal.
|
|
187
|
+
*/
|
|
188
|
+
private readonly defaultPrincipal;
|
|
170
189
|
/** Connections that already logged a "no store session" warning. */
|
|
171
190
|
private warnedNoSession;
|
|
172
191
|
/**
|
|
@@ -228,6 +247,8 @@ export declare class Bridge implements IBridge {
|
|
|
228
247
|
getBoundPort(): number | undefined;
|
|
229
248
|
getViewerBaseUrl(): string | undefined;
|
|
230
249
|
getAuthToken(): string | undefined;
|
|
250
|
+
/** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
|
|
251
|
+
getAuthOptions(): AuthOptions;
|
|
231
252
|
/**
|
|
232
253
|
* Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
|
|
233
254
|
*
|
|
@@ -264,9 +285,9 @@ export declare class Bridge implements IBridge {
|
|
|
264
285
|
status?: TaskStatus | 'all';
|
|
265
286
|
limit?: number;
|
|
266
287
|
}): Promise<Task[]>;
|
|
267
|
-
claimTask(id: string): Promise<Task | undefined>;
|
|
288
|
+
claimTask(id: string, principal?: Principal): Promise<Task | undefined>;
|
|
268
289
|
getTaskAttachmentData(taskId: string, attachmentId: string): Promise<string | null>;
|
|
269
|
-
resolveTask(id: string, note?: string): Promise<Task | undefined>;
|
|
290
|
+
resolveTask(id: string, note?: string, principal?: Principal): Promise<Task | undefined>;
|
|
270
291
|
private persistTaskEvent;
|
|
271
292
|
private recordTask;
|
|
272
293
|
/**
|
package/dist/bridge.js
CHANGED
|
@@ -14,7 +14,8 @@ import { WebSocket, WebSocketServer } from 'ws';
|
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
15
|
import { createServer } from 'node:http';
|
|
16
16
|
import { networkInterfaces } from 'node:os';
|
|
17
|
-
import { DEFAULT_LOGIN_PATH, handleLoginPost, isAuthorized, sendUnauthorized, } from './auth.js';
|
|
17
|
+
import { DEFAULT_LOGIN_PATH, handleLoginPost, isAuthEnabled, isAuthorized, sendUnauthorized, } from './auth.js';
|
|
18
|
+
import { LOCAL_PRINCIPAL, resolvePrincipal } from './identity.js';
|
|
18
19
|
import { join as joinPath } from 'node:path';
|
|
19
20
|
import { homedir } from 'node:os';
|
|
20
21
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -59,6 +60,8 @@ export class Bridge {
|
|
|
59
60
|
tasks = new Map();
|
|
60
61
|
opts;
|
|
61
62
|
auth;
|
|
63
|
+
/** Browser-consent policy pushed to runtime clients in hello.ack (4.0 · P2). */
|
|
64
|
+
consentPolicy;
|
|
62
65
|
publicHostOverride;
|
|
63
66
|
attachDataDir;
|
|
64
67
|
autoPurgeOpts;
|
|
@@ -69,6 +72,15 @@ export class Bridge {
|
|
|
69
72
|
* or sessionId (for runtime-client connections).
|
|
70
73
|
*/
|
|
71
74
|
connToStoreId = new Map();
|
|
75
|
+
/** Caller identity per connection (4.0 · P1). Resolved at WS upgrade. */
|
|
76
|
+
connToPrincipal = new Map();
|
|
77
|
+
/**
|
|
78
|
+
* Identity attributed to MCP-driven writes (task claim/resolve). The MCP
|
|
79
|
+
* tool layer is a daemon-wide singleton today with no per-call caller
|
|
80
|
+
* context, so stdio/HTTP agents collapse to one principal. P4 (per-session
|
|
81
|
+
* MCP transport) replaces this with the real per-call principal.
|
|
82
|
+
*/
|
|
83
|
+
defaultPrincipal = LOCAL_PRINCIPAL;
|
|
72
84
|
/** Connections that already logged a "no store session" warning. */
|
|
73
85
|
warnedNoSession = new Set();
|
|
74
86
|
/**
|
|
@@ -107,6 +119,12 @@ export class Bridge {
|
|
|
107
119
|
host: opts.host ?? '127.0.0.1',
|
|
108
120
|
};
|
|
109
121
|
this.auth = opts.auth ?? {};
|
|
122
|
+
// Consent defaults track the auth boundary: exposed (auth on) ⇒ prompt
|
|
123
|
+
// once per session; loopback solo (auth off) ⇒ no prompts. Explicit
|
|
124
|
+
// opts.consent always wins.
|
|
125
|
+
this.consentPolicy = opts.consent ?? {
|
|
126
|
+
mode: isAuthEnabled(this.auth) ? 'session' : 'off',
|
|
127
|
+
};
|
|
110
128
|
this.publicHostOverride = opts.publicHost;
|
|
111
129
|
// Default auto-purge ON. CI / tests pass `enabled: false` (or set
|
|
112
130
|
// env HARNESS_FE_PURGE_DISABLED=1) to opt out.
|
|
@@ -272,7 +290,7 @@ export class Bridge {
|
|
|
272
290
|
res.end('Not Found');
|
|
273
291
|
});
|
|
274
292
|
const wss = new WebSocketServer({ noServer: true });
|
|
275
|
-
wss.on('connection', (ws) => this.onConnection(ws));
|
|
293
|
+
wss.on('connection', (ws, req) => this.onConnection(ws, req));
|
|
276
294
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
277
295
|
if (!isAuthorized(req, this.auth)) {
|
|
278
296
|
// Spec-compliant 401 on the upgrade reply so client sees a
|
|
@@ -379,6 +397,10 @@ export class Bridge {
|
|
|
379
397
|
getAuthToken() {
|
|
380
398
|
return this.auth.token;
|
|
381
399
|
}
|
|
400
|
+
/** Daemon auth options — used by the MCP layer to identify the per-call principal (4.0 · P4). */
|
|
401
|
+
getAuthOptions() {
|
|
402
|
+
return this.auth;
|
|
403
|
+
}
|
|
382
404
|
/**
|
|
383
405
|
* Broadcast a `dashboard.update` frame to every subscribed dashboard SPA.
|
|
384
406
|
*
|
|
@@ -539,12 +561,16 @@ export class Bridge {
|
|
|
539
561
|
filtered.sort((a, b) => b.createdAt - a.createdAt);
|
|
540
562
|
return filtered.slice(0, limit);
|
|
541
563
|
}
|
|
542
|
-
async claimTask(id) {
|
|
564
|
+
async claimTask(id, principal) {
|
|
543
565
|
const task = this.tasks.get(id);
|
|
544
566
|
if (!task)
|
|
545
567
|
return undefined;
|
|
546
568
|
task.status = 'claimed';
|
|
547
569
|
task.claimedAt = Date.now();
|
|
570
|
+
// Tag which agent picked it up (4.0 · P1/P4). The per-call principal
|
|
571
|
+
// (HTTP MCP, resolved from request headers) wins; stdio / no-caller
|
|
572
|
+
// falls back to the daemon's local principal.
|
|
573
|
+
task.agentId = (principal ?? this.defaultPrincipal).id;
|
|
548
574
|
this.persistTasks();
|
|
549
575
|
// Persist status change to store
|
|
550
576
|
this.persistTaskEvent(task, 'task:claim');
|
|
@@ -556,7 +582,7 @@ export class Bridge {
|
|
|
556
582
|
return null;
|
|
557
583
|
return this.readTaskAttachment(task.projectId, taskId, attachmentId);
|
|
558
584
|
}
|
|
559
|
-
async resolveTask(id, note) {
|
|
585
|
+
async resolveTask(id, note, principal) {
|
|
560
586
|
const task = this.tasks.get(id);
|
|
561
587
|
if (!task)
|
|
562
588
|
return undefined;
|
|
@@ -564,6 +590,8 @@ export class Bridge {
|
|
|
564
590
|
task.resolvedAt = Date.now();
|
|
565
591
|
if (note !== undefined)
|
|
566
592
|
task.note = note;
|
|
593
|
+
if (!task.agentId)
|
|
594
|
+
task.agentId = (principal ?? this.defaultPrincipal).id;
|
|
567
595
|
this.persistTasks();
|
|
568
596
|
// Persist status change to store
|
|
569
597
|
this.persistTaskEvent(task, 'task:resolve');
|
|
@@ -818,9 +846,13 @@ export class Bridge {
|
|
|
818
846
|
}
|
|
819
847
|
return false;
|
|
820
848
|
}
|
|
821
|
-
onConnection(ws) {
|
|
849
|
+
onConnection(ws, req) {
|
|
822
850
|
const connectionId = randomUUID();
|
|
823
851
|
this.sockets.set(connectionId, ws);
|
|
852
|
+
// Resolve caller identity once at connection time (4.0 · P1). The
|
|
853
|
+
// upgrade handler already enforced isAuthorized, so resolvePrincipal
|
|
854
|
+
// won't reject here; fall back to LOCAL for the loopback / no-req path.
|
|
855
|
+
this.connToPrincipal.set(connectionId, (req && resolvePrincipal(req, this.auth)) || LOCAL_PRINCIPAL);
|
|
824
856
|
ws.on('message', (raw) => {
|
|
825
857
|
let parsed;
|
|
826
858
|
try {
|
|
@@ -880,6 +912,7 @@ export class Bridge {
|
|
|
880
912
|
}
|
|
881
913
|
}
|
|
882
914
|
this.router.unregister(connectionId);
|
|
915
|
+
this.connToPrincipal.delete(connectionId);
|
|
883
916
|
});
|
|
884
917
|
ws.on('error', () => {
|
|
885
918
|
/* swallow; close will follow */
|
|
@@ -924,6 +957,7 @@ export class Bridge {
|
|
|
924
957
|
// production / staging deployment where the bundler plugin is
|
|
925
958
|
// absent. The runtime-client branch below opens its own store
|
|
926
959
|
// session if one does not already exist for this project.
|
|
960
|
+
const principal = this.connToPrincipal.get(connectionId) ?? LOCAL_PRINCIPAL;
|
|
927
961
|
const session = this.router.register({
|
|
928
962
|
role: frame.role,
|
|
929
963
|
projectId: frame.projectId,
|
|
@@ -933,6 +967,7 @@ export class Bridge {
|
|
|
933
967
|
userId: frame.userId,
|
|
934
968
|
connectionId,
|
|
935
969
|
page: frame.page,
|
|
970
|
+
principal,
|
|
936
971
|
});
|
|
937
972
|
// Persist to store
|
|
938
973
|
if (this.store) {
|
|
@@ -944,6 +979,7 @@ export class Bridge {
|
|
|
944
979
|
this.store.upsertProject(frame.projectId, {
|
|
945
980
|
parentProjectId: frame.parentProjectId,
|
|
946
981
|
displayName: frame.displayName,
|
|
982
|
+
createdBy: principal.id,
|
|
947
983
|
});
|
|
948
984
|
}
|
|
949
985
|
catch (err) {
|
|
@@ -1018,6 +1054,7 @@ export class Bridge {
|
|
|
1018
1054
|
referrer: undefined,
|
|
1019
1055
|
userAgent: frame.page?.userAgent,
|
|
1020
1056
|
participants,
|
|
1057
|
+
createdBy: principal.id,
|
|
1021
1058
|
});
|
|
1022
1059
|
this.connToStoreId.set(connectionId, sessionId);
|
|
1023
1060
|
this.notifyDashboard({
|
|
@@ -1060,6 +1097,7 @@ export class Bridge {
|
|
|
1060
1097
|
id: frame.id,
|
|
1061
1098
|
tabId: session.tabId,
|
|
1062
1099
|
serverVersion: PROTOCOL_VERSION,
|
|
1100
|
+
consent: this.consentPolicy,
|
|
1063
1101
|
};
|
|
1064
1102
|
ws.send(JSON.stringify(ack));
|
|
1065
1103
|
// One concise line per accepted peer. Visibility for "is the
|
package/dist/daemon.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* not pushed into the factory.
|
|
24
24
|
*/
|
|
25
25
|
import type { IncomingMessage } from 'node:http';
|
|
26
|
+
import type { ConsentPolicy } from '@harness-fe/protocol';
|
|
26
27
|
import { Bridge } from './bridge.js';
|
|
27
28
|
import type { EventStore, IStore } from './store/types.js';
|
|
28
29
|
import type { ITaskStore, IMemoryStore } from './store/types.js';
|
|
@@ -56,6 +57,12 @@ export interface DaemonOptions {
|
|
|
56
57
|
* flows through here. Mutually exclusive with `authorize`.
|
|
57
58
|
*/
|
|
58
59
|
token?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Browser-consent policy for control commands (4.0 · P2). Omit to track the
|
|
62
|
+
* auth boundary automatically: `session` when auth is on (exposed daemon),
|
|
63
|
+
* `off` on loopback solo dev. Pass `{ mode }` to force a policy.
|
|
64
|
+
*/
|
|
65
|
+
consent?: ConsentPolicy;
|
|
59
66
|
/**
|
|
60
67
|
* IStore implementation. Omit for the default JSONL store at `dataDir`.
|
|
61
68
|
* Pass `null` to disable session/event persistence entirely.
|
package/dist/daemon.js
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
export {};
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
}
|
package/dist/mcp.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
9
|
import type { IBridge } from './bridge.js';
|
|
10
|
+
import type { AuthOptions } from './auth.js';
|
|
10
11
|
export interface McpServerOptions {
|
|
11
12
|
/**
|
|
12
13
|
* Name of the environment variable that gates experimental tools.
|
|
@@ -17,6 +18,12 @@ export interface McpServerOptions {
|
|
|
17
18
|
* var is set to a non-empty value at server-construction time.
|
|
18
19
|
*/
|
|
19
20
|
experimentalEnvVar?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Daemon auth options, used to identify the per-call principal from MCP
|
|
23
|
+
* request headers (4.0 · P4). Omit for stdio / no-auth — calls resolve to
|
|
24
|
+
* the local principal.
|
|
25
|
+
*/
|
|
26
|
+
auth?: AuthOptions;
|
|
20
27
|
}
|
|
21
28
|
/**
|
|
22
29
|
* Experimental-feature gate.
|
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 { 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)) {
|
|
@@ -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: {
|
|
@@ -480,8 +481,9 @@ function registerTools(server, bridge) {
|
|
|
480
481
|
inputSchema: {
|
|
481
482
|
taskId: z.string(),
|
|
482
483
|
},
|
|
483
|
-
}, async ({ taskId }) => {
|
|
484
|
-
const
|
|
484
|
+
}, async ({ taskId }, extra) => {
|
|
485
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
486
|
+
const task = await bridge.claimTask(taskId, principal);
|
|
485
487
|
if (!task) {
|
|
486
488
|
throw new Error(`tasks.claim: no task with id "${taskId}"`);
|
|
487
489
|
}
|
|
@@ -493,8 +495,9 @@ function registerTools(server, bridge) {
|
|
|
493
495
|
taskId: z.string(),
|
|
494
496
|
note: z.string().optional(),
|
|
495
497
|
},
|
|
496
|
-
}, async ({ taskId, note }) => {
|
|
497
|
-
const
|
|
498
|
+
}, async ({ taskId, note }, extra) => {
|
|
499
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
500
|
+
const task = await bridge.resolveTask(taskId, note, principal);
|
|
498
501
|
if (!task) {
|
|
499
502
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
500
503
|
}
|
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.1",
|
|
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",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"rrweb-player": "1.0.0-alpha.4",
|
|
39
39
|
"ws": "^8.18.0",
|
|
40
40
|
"zod": "^4.4.3",
|
|
41
|
-
"@harness-fe/
|
|
42
|
-
"@harness-fe/
|
|
41
|
+
"@harness-fe/dashboard-ui": "0.2.0",
|
|
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;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import {
|
|
4
|
+
HOST_PRINCIPAL,
|
|
5
|
+
LOCAL_PRINCIPAL,
|
|
6
|
+
identifyPrincipal,
|
|
7
|
+
resolvePrincipal,
|
|
8
|
+
tokenPrincipalId,
|
|
9
|
+
} from './identity.js';
|
|
10
|
+
|
|
11
|
+
function fakeReq(init: { headers?: Record<string, string>; url?: string } = {}): IncomingMessage {
|
|
12
|
+
return {
|
|
13
|
+
headers: init.headers ?? {},
|
|
14
|
+
url: init.url ?? '/',
|
|
15
|
+
} as unknown as IncomingMessage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('identity: tokenPrincipalId', () => {
|
|
19
|
+
it('is stable + prefixed + hashed (never the raw token)', () => {
|
|
20
|
+
const id = tokenPrincipalId('super-secret');
|
|
21
|
+
expect(id).toMatch(/^token:[0-9a-f]{12}$/);
|
|
22
|
+
expect(id).toBe(tokenPrincipalId('super-secret'));
|
|
23
|
+
expect(id).not.toContain('super-secret');
|
|
24
|
+
});
|
|
25
|
+
it('different tokens → different ids', () => {
|
|
26
|
+
expect(tokenPrincipalId('a')).not.toBe(tokenPrincipalId('b'));
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('identity: resolvePrincipal', () => {
|
|
31
|
+
it('loopback (auth disabled) → LOCAL_PRINCIPAL', () => {
|
|
32
|
+
expect(resolvePrincipal(fakeReq(), {})).toBe(LOCAL_PRINCIPAL);
|
|
33
|
+
expect(resolvePrincipal(fakeReq(), { token: '' })).toBe(LOCAL_PRINCIPAL);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('token mode: matching token → token principal', () => {
|
|
37
|
+
const req = fakeReq({ headers: { authorization: 'Bearer s3cr3t' } });
|
|
38
|
+
const p = resolvePrincipal(req, { token: 's3cr3t' });
|
|
39
|
+
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('token mode: missing/wrong token → null (mirrors deny)', () => {
|
|
43
|
+
expect(resolvePrincipal(fakeReq(), { token: 's3cr3t' })).toBeNull();
|
|
44
|
+
expect(
|
|
45
|
+
resolvePrincipal(fakeReq({ headers: { authorization: 'Bearer nope' } }), { token: 's3cr3t' }),
|
|
46
|
+
).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('custom authorize: accept → HOST_PRINCIPAL, reject → null', () => {
|
|
50
|
+
expect(resolvePrincipal(fakeReq(), { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
51
|
+
expect(resolvePrincipal(fakeReq(), { authorize: () => false })).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('authorize wins over token (same precedence as isAuthorized)', () => {
|
|
55
|
+
const req = fakeReq({ headers: { authorization: 'Bearer wrong' } });
|
|
56
|
+
expect(resolvePrincipal(req, { token: 'right', authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
|
|
61
|
+
it('no auth → local', () => {
|
|
62
|
+
expect(identifyPrincipal({ authorization: 'Bearer x' }, {})).toBe(LOCAL_PRINCIPAL);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('stdio (no headers) → local even when a token is configured', () => {
|
|
66
|
+
expect(identifyPrincipal(undefined, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('token mode: names the caller from the Authorization header', () => {
|
|
70
|
+
const p = identifyPrincipal({ authorization: 'Bearer s3cr3t' }, { token: 's3cr3t' });
|
|
71
|
+
expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('token mode: handles array-valued headers', () => {
|
|
75
|
+
const p = identifyPrincipal({ authorization: ['Bearer s3cr3t'] }, { token: 's3cr3t' });
|
|
76
|
+
expect(p.id).toBe(tokenPrincipalId('s3cr3t'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('token mode without a bearer header → local (already past auth wrapper)', () => {
|
|
80
|
+
expect(identifyPrincipal({}, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('authorize mode → host', () => {
|
|
84
|
+
expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import type { IncomingMessage } from 'node:http';
|
|
21
|
+
|
|
22
|
+
import { extractToken, isAuthEnabled, verifyToken, type AuthOptions } from './auth.js';
|
|
23
|
+
|
|
24
|
+
export type PrincipalKind = 'local' | 'token' | 'host';
|
|
25
|
+
|
|
26
|
+
export interface Principal {
|
|
27
|
+
/** Stable id for this caller. Loopback / stdio solo dev → `local`. */
|
|
28
|
+
id: string;
|
|
29
|
+
/** How the identity was established. */
|
|
30
|
+
kind: PrincipalKind;
|
|
31
|
+
/** Optional human-readable label (for dashboards / audit). */
|
|
32
|
+
displayName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The implicit single principal for loopback and stdio solo dev. The daemon
|
|
37
|
+
* trusts everything that can reach the loopback socket, so there is one
|
|
38
|
+
* caller and it owns everything — exactly today's behaviour, now named.
|
|
39
|
+
*/
|
|
40
|
+
export const LOCAL_PRINCIPAL: Principal = Object.freeze({
|
|
41
|
+
id: 'local',
|
|
42
|
+
kind: 'local',
|
|
43
|
+
displayName: 'local',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Principal for the custom-`authorize` path. Hosts that embed the daemon own
|
|
48
|
+
* their own user model; until `authorize` can return a richer identity
|
|
49
|
+
* (future work), an authorized host caller maps to this single principal.
|
|
50
|
+
*/
|
|
51
|
+
export const HOST_PRINCIPAL: Principal = Object.freeze({
|
|
52
|
+
id: 'host',
|
|
53
|
+
kind: 'host',
|
|
54
|
+
displayName: 'host',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive a stable principal id from a bearer token. One token = one principal
|
|
59
|
+
* in 4.0's trusted-team model. We hash so the raw secret never becomes an id
|
|
60
|
+
* that could leak into stored `createdBy` tags or audit logs.
|
|
61
|
+
*/
|
|
62
|
+
export function tokenPrincipalId(token: string): string {
|
|
63
|
+
return `token:${createHash('sha256').update(token).digest('hex').slice(0, 12)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the caller behind a request.
|
|
68
|
+
*
|
|
69
|
+
* Returns `null` when auth is enabled and the request is NOT authorized — this
|
|
70
|
+
* mirrors `isAuthorized(req) === false` exactly, so callers can treat a null
|
|
71
|
+
* principal as "reject" without a second auth check.
|
|
72
|
+
*
|
|
73
|
+
* - auth disabled (loopback): {@link LOCAL_PRINCIPAL}
|
|
74
|
+
* - custom `authorize`: {@link HOST_PRINCIPAL} when it accepts, else `null`
|
|
75
|
+
* - token: a {@link tokenPrincipalId}-derived principal when it matches, else `null`
|
|
76
|
+
*/
|
|
77
|
+
export function resolvePrincipal(req: IncomingMessage, opts: AuthOptions): Principal | null {
|
|
78
|
+
if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
|
|
79
|
+
if (opts.authorize) return opts.authorize(req) ? HOST_PRINCIPAL : null;
|
|
80
|
+
const token = extractToken(req, opts);
|
|
81
|
+
if (!verifyToken(token, opts.token!)) return null;
|
|
82
|
+
return { id: tokenPrincipalId(token!), kind: 'token' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
86
|
+
|
|
87
|
+
function bearerFromHeaders(headers: HeaderBag): string | undefined {
|
|
88
|
+
const raw = headers['authorization'] ?? headers['Authorization'];
|
|
89
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
90
|
+
if (typeof v === 'string' && v.startsWith('Bearer ')) {
|
|
91
|
+
const t = v.slice(7).trim();
|
|
92
|
+
if (t) return t;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Identify (not authorize) the caller behind an MCP tool call (4.0 · P4).
|
|
99
|
+
*
|
|
100
|
+
* The HTTP MCP request has already cleared the bridge's auth wrapper by the
|
|
101
|
+
* time a tool runs, so this only needs to *name* the caller — never to
|
|
102
|
+
* re-check them. Pass the per-request headers from the MCP SDK's
|
|
103
|
+
* `extra.requestInfo`; stdio calls have no requestInfo and resolve to `local`
|
|
104
|
+
* (the daemon trusts its local stdio agent).
|
|
105
|
+
*
|
|
106
|
+
* - auth disabled, or no headers (stdio) → {@link LOCAL_PRINCIPAL}
|
|
107
|
+
* - custom authorize → {@link HOST_PRINCIPAL}
|
|
108
|
+
* - token mode → token principal from the Authorization header (LOCAL if absent)
|
|
109
|
+
*/
|
|
110
|
+
export function identifyPrincipal(headers: HeaderBag | undefined, opts: AuthOptions): Principal {
|
|
111
|
+
if (!isAuthEnabled(opts)) return LOCAL_PRINCIPAL;
|
|
112
|
+
if (opts.authorize) return HOST_PRINCIPAL;
|
|
113
|
+
if (!headers) return LOCAL_PRINCIPAL;
|
|
114
|
+
const token = bearerFromHeaders(headers);
|
|
115
|
+
return token ? { id: tokenPrincipalId(token), kind: 'token' } : LOCAL_PRINCIPAL;
|
|
116
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from '@harness-fe/protocol';
|
|
27
27
|
import type { IBridge } from './bridge.js';
|
|
28
28
|
import type { Bridge } from './bridge.js';
|
|
29
|
+
import type { AuthOptions } from './auth.js';
|
|
30
|
+
import { identifyPrincipal } from './identity.js';
|
|
29
31
|
import { RemoteBridge } from './remoteBridge.js';
|
|
30
32
|
import type { IStore, IMemoryStore } from './store/index.js';
|
|
31
33
|
import { buildVisitorTimeline } from './visitorTimeline.js';
|
|
@@ -45,6 +47,12 @@ export interface McpServerOptions {
|
|
|
45
47
|
* var is set to a non-empty value at server-construction time.
|
|
46
48
|
*/
|
|
47
49
|
experimentalEnvVar?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Daemon auth options, used to identify the per-call principal from MCP
|
|
52
|
+
* request headers (4.0 · P4). Omit for stdio / no-auth — calls resolve to
|
|
53
|
+
* the local principal.
|
|
54
|
+
*/
|
|
55
|
+
auth?: AuthOptions;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/**
|
|
@@ -81,7 +89,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
|
|
|
81
89
|
version: PROTOCOL_VERSION,
|
|
82
90
|
});
|
|
83
91
|
|
|
84
|
-
registerTools(server, bridge);
|
|
92
|
+
registerTools(server, bridge, options.auth);
|
|
85
93
|
|
|
86
94
|
// Experimental tools are on by default. They only get gated when the host
|
|
87
95
|
// supplies an env-var name to key off; see experimentalEnabled().
|
|
@@ -134,7 +142,7 @@ function err(message: string): {
|
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
|
|
137
|
-
function registerTools(server: McpServer, bridge: IBridge): void {
|
|
145
|
+
function registerTools(server: McpServer, bridge: IBridge, auth?: AuthOptions): void {
|
|
138
146
|
server.registerTool(
|
|
139
147
|
COMMAND.PAGE_CLICK,
|
|
140
148
|
{
|
|
@@ -769,8 +777,9 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
769
777
|
taskId: z.string(),
|
|
770
778
|
},
|
|
771
779
|
},
|
|
772
|
-
async ({ taskId }) => {
|
|
773
|
-
const
|
|
780
|
+
async ({ taskId }, extra) => {
|
|
781
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
782
|
+
const task = await bridge.claimTask(taskId, principal);
|
|
774
783
|
if (!task) {
|
|
775
784
|
throw new Error(`tasks.claim: no task with id "${taskId}"`);
|
|
776
785
|
}
|
|
@@ -788,8 +797,9 @@ function registerTools(server: McpServer, bridge: IBridge): void {
|
|
|
788
797
|
note: z.string().optional(),
|
|
789
798
|
},
|
|
790
799
|
},
|
|
791
|
-
async ({ taskId, note }) => {
|
|
792
|
-
const
|
|
800
|
+
async ({ taskId, note }, extra) => {
|
|
801
|
+
const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
|
|
802
|
+
const task = await bridge.resolveTask(taskId, note, principal);
|
|
793
803
|
if (!task) {
|
|
794
804
|
throw new Error(`tasks.resolve: no task with id "${taskId}"`);
|
|
795
805
|
}
|
package/src/mcpHttp.ts
CHANGED
|
@@ -61,7 +61,13 @@ export async function startMcpHttpServer(
|
|
|
61
61
|
? undefined
|
|
62
62
|
: opts.eventStore ?? new MemoryEventStore();
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// Pass the daemon's auth so MCP tools can identify the per-call principal
|
|
65
|
+
// from request headers (4.0 · P4). stdio (startMcpStdioServer) omits this,
|
|
66
|
+
// so stdio calls resolve to the local principal.
|
|
67
|
+
const server = createMcpServer(bridge, {
|
|
68
|
+
experimentalEnvVar: opts.experimentalEnvVar,
|
|
69
|
+
auth: (bridge as Bridge).getAuthOptions(),
|
|
70
|
+
});
|
|
65
71
|
const transport = new StreamableHTTPServerTransport({
|
|
66
72
|
sessionIdGenerator: stateful ? () => randomUUID() : undefined,
|
|
67
73
|
eventStore,
|
package/src/sessionRouter.ts
CHANGED
|
@@ -12,11 +12,14 @@ import type {
|
|
|
12
12
|
PeerRole,
|
|
13
13
|
TabInfo,
|
|
14
14
|
} from '@harness-fe/protocol';
|
|
15
|
+
import type { Principal } from './identity.js';
|
|
15
16
|
|
|
16
17
|
export interface PeerSession {
|
|
17
18
|
role: PeerRole;
|
|
18
19
|
projectId: string;
|
|
19
20
|
tabId?: string;
|
|
21
|
+
/** Caller identity behind this connection (4.0 · P1). Defaults to `local`. */
|
|
22
|
+
principal?: Principal;
|
|
20
23
|
/** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
|
|
21
24
|
sessionId?: string;
|
|
22
25
|
/** Runtime-client only: stable per-browser visitor identifier. */
|
package/src/store/JsonlStore.ts
CHANGED
|
@@ -483,6 +483,8 @@ export class JsonlStore implements IStore {
|
|
|
483
483
|
...existing,
|
|
484
484
|
...meta,
|
|
485
485
|
id: sessionId,
|
|
486
|
+
// Write-once: first principal to open the session owns it.
|
|
487
|
+
createdBy: existing?.createdBy ?? meta.createdBy,
|
|
486
488
|
};
|
|
487
489
|
// Reset participants — we'll rebuild via dedup loop below
|
|
488
490
|
merged.participants = [];
|
|
@@ -650,6 +652,8 @@ export class JsonlStore implements IStore {
|
|
|
650
652
|
id: projectId,
|
|
651
653
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
652
654
|
lastActiveAt: Date.now(),
|
|
655
|
+
// Write-once: the first principal to create the project owns it.
|
|
656
|
+
createdBy: existing?.createdBy ?? patch.createdBy,
|
|
653
657
|
};
|
|
654
658
|
enforceExtensionBudget(merged, `project ${projectId}`);
|
|
655
659
|
writeJson(metaPath, merged);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller-identity tagging (4.0 · P1): `createdBy` is write-once on project /
|
|
3
|
+
* session metadata — the first principal to create the record owns it, and
|
|
4
|
+
* later upserts (which never carry an identity in normal flow) must not
|
|
5
|
+
* silently re-attribute ownership.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { JsonlStore } from './JsonlStore.js';
|
|
13
|
+
|
|
14
|
+
describe('store: createdBy tagging (P1)', () => {
|
|
15
|
+
let dir: string;
|
|
16
|
+
let store: JsonlStore;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), 'harness-identity-test-'));
|
|
20
|
+
store = new JsonlStore(dir);
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
store.close();
|
|
24
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('project: records createdBy on creation', () => {
|
|
28
|
+
const p = store.upsertProject('proj-a', { createdBy: 'token:abc' });
|
|
29
|
+
expect(p.createdBy).toBe('token:abc');
|
|
30
|
+
expect(store.getProject('proj-a')?.createdBy).toBe('token:abc');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('project: createdBy is write-once (later upsert without it is preserved)', () => {
|
|
34
|
+
store.upsertProject('proj-a', { createdBy: 'local' });
|
|
35
|
+
const after = store.upsertProject('proj-a', { displayName: 'renamed' });
|
|
36
|
+
expect(after.createdBy).toBe('local');
|
|
37
|
+
expect(after.displayName).toBe('renamed');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('project: a later upsert cannot re-attribute ownership', () => {
|
|
41
|
+
store.upsertProject('proj-a', { createdBy: 'local' });
|
|
42
|
+
const after = store.upsertProject('proj-a', { createdBy: 'token:evil' });
|
|
43
|
+
expect(after.createdBy).toBe('local');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('project: createdBy stays undefined when never supplied (back-compat)', () => {
|
|
47
|
+
const p = store.upsertProject('proj-legacy', { displayName: 'x' });
|
|
48
|
+
expect(p.createdBy).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('session: records and locks createdBy', () => {
|
|
52
|
+
const sid = randomUUID();
|
|
53
|
+
store.upsertSession(sid, {
|
|
54
|
+
tabId: 'tab-1',
|
|
55
|
+
startedAt: Date.now(),
|
|
56
|
+
participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
|
|
57
|
+
createdBy: 'local',
|
|
58
|
+
});
|
|
59
|
+
store.upsertSession(sid, {
|
|
60
|
+
tabId: 'tab-1',
|
|
61
|
+
startedAt: Date.now(),
|
|
62
|
+
participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
|
|
63
|
+
createdBy: 'token:other',
|
|
64
|
+
});
|
|
65
|
+
expect(store.getSession(sid)?.createdBy).toBe('local');
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/store/types.ts
CHANGED
|
@@ -104,6 +104,12 @@ export interface ProjectMeta {
|
|
|
104
104
|
parentProjectId?: string;
|
|
105
105
|
displayName?: string;
|
|
106
106
|
tags?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* Caller-identity tag (4.0 · P1): principal id that first created this
|
|
109
|
+
* project. Write-once (locked on creation like `createdAt`); informational
|
|
110
|
+
* in P1, the basis for `project → agent` routing/isolation in P3.
|
|
111
|
+
*/
|
|
112
|
+
createdBy?: string;
|
|
107
113
|
metadata?: Record<string, unknown>;
|
|
108
114
|
}
|
|
109
115
|
|
|
@@ -173,6 +179,11 @@ export interface SessionMeta {
|
|
|
173
179
|
storageKeys?: { local?: number; session?: number; cookie?: number };
|
|
174
180
|
storageTruncated?: boolean;
|
|
175
181
|
};
|
|
182
|
+
/**
|
|
183
|
+
* Caller-identity tag (4.0 · P1): principal id of the connection that
|
|
184
|
+
* opened this session. Write-once. Informational in P1.
|
|
185
|
+
*/
|
|
186
|
+
createdBy?: string;
|
|
176
187
|
metadata?: Record<string, unknown>;
|
|
177
188
|
}
|
|
178
189
|
|