@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.
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IncomingMessage } from 'node:http';
3
+ import {
4
+ HOST_PRINCIPAL,
5
+ LOCAL_PRINCIPAL,
6
+ canSee,
7
+ identifyPrincipal,
8
+ resolvePrincipal,
9
+ tokenPrincipalId,
10
+ } from './identity.js';
11
+
12
+ function fakeReq(init: { headers?: Record<string, string>; url?: string } = {}): IncomingMessage {
13
+ return {
14
+ headers: init.headers ?? {},
15
+ url: init.url ?? '/',
16
+ } as unknown as IncomingMessage;
17
+ }
18
+
19
+ describe('identity: tokenPrincipalId', () => {
20
+ it('is stable + prefixed + hashed (never the raw token)', () => {
21
+ const id = tokenPrincipalId('super-secret');
22
+ expect(id).toMatch(/^token:[0-9a-f]{12}$/);
23
+ expect(id).toBe(tokenPrincipalId('super-secret'));
24
+ expect(id).not.toContain('super-secret');
25
+ });
26
+ it('different tokens → different ids', () => {
27
+ expect(tokenPrincipalId('a')).not.toBe(tokenPrincipalId('b'));
28
+ });
29
+ });
30
+
31
+ describe('identity: resolvePrincipal', () => {
32
+ it('loopback (auth disabled) → LOCAL_PRINCIPAL', () => {
33
+ expect(resolvePrincipal(fakeReq(), {})).toBe(LOCAL_PRINCIPAL);
34
+ expect(resolvePrincipal(fakeReq(), { token: '' })).toBe(LOCAL_PRINCIPAL);
35
+ });
36
+
37
+ it('token mode: matching token → token principal', () => {
38
+ const req = fakeReq({ headers: { authorization: 'Bearer s3cr3t' } });
39
+ const p = resolvePrincipal(req, { token: 's3cr3t' });
40
+ expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
41
+ });
42
+
43
+ it('token mode: missing/wrong token → null (mirrors deny)', () => {
44
+ expect(resolvePrincipal(fakeReq(), { token: 's3cr3t' })).toBeNull();
45
+ expect(
46
+ resolvePrincipal(fakeReq({ headers: { authorization: 'Bearer nope' } }), { token: 's3cr3t' }),
47
+ ).toBeNull();
48
+ });
49
+
50
+ it('custom authorize: accept → HOST_PRINCIPAL, reject → null', () => {
51
+ expect(resolvePrincipal(fakeReq(), { authorize: () => true })).toBe(HOST_PRINCIPAL);
52
+ expect(resolvePrincipal(fakeReq(), { authorize: () => false })).toBeNull();
53
+ });
54
+
55
+ it('authorize wins over token (same precedence as isAuthorized)', () => {
56
+ const req = fakeReq({ headers: { authorization: 'Bearer wrong' } });
57
+ expect(resolvePrincipal(req, { token: 'right', authorize: () => true })).toBe(HOST_PRINCIPAL);
58
+ });
59
+ });
60
+
61
+ describe('identity: identifyPrincipal (P4 — identify, not authorize)', () => {
62
+ it('no auth → local', () => {
63
+ expect(identifyPrincipal({ authorization: 'Bearer x' }, {})).toBe(LOCAL_PRINCIPAL);
64
+ });
65
+
66
+ it('stdio (no headers) → local even when a token is configured', () => {
67
+ expect(identifyPrincipal(undefined, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
68
+ });
69
+
70
+ it('token mode: names the caller from the Authorization header', () => {
71
+ const p = identifyPrincipal({ authorization: 'Bearer s3cr3t' }, { token: 's3cr3t' });
72
+ expect(p).toEqual({ id: tokenPrincipalId('s3cr3t'), kind: 'token' });
73
+ });
74
+
75
+ it('token mode: handles array-valued headers', () => {
76
+ const p = identifyPrincipal({ authorization: ['Bearer s3cr3t'] }, { token: 's3cr3t' });
77
+ expect(p.id).toBe(tokenPrincipalId('s3cr3t'));
78
+ });
79
+
80
+ it('token mode without a bearer header → local (already past auth wrapper)', () => {
81
+ expect(identifyPrincipal({}, { token: 's3cr3t' })).toBe(LOCAL_PRINCIPAL);
82
+ });
83
+
84
+ it('authorize mode → host', () => {
85
+ expect(identifyPrincipal({ authorization: 'Bearer x' }, { authorize: () => true })).toBe(HOST_PRINCIPAL);
86
+ });
87
+ });
88
+
89
+ describe('identity: canSee (P3 tenant visibility)', () => {
90
+ const tokenA = { id: 'token:aaa', kind: 'token' as const };
91
+ const tokenB = { id: 'token:bbb', kind: 'token' as const };
92
+
93
+ it('local sees everything (zero behaviour change for solo dev)', () => {
94
+ expect(canSee(LOCAL_PRINCIPAL, 'token:aaa')).toBe(true);
95
+ expect(canSee(LOCAL_PRINCIPAL, undefined)).toBe(true);
96
+ expect(canSee(LOCAL_PRINCIPAL, null)).toBe(true);
97
+ });
98
+
99
+ it('unowned data (no createdBy) is visible to everyone', () => {
100
+ expect(canSee(tokenA, undefined)).toBe(true);
101
+ expect(canSee(tokenA, null)).toBe(true);
102
+ });
103
+
104
+ it('named principal sees only its own owned data', () => {
105
+ expect(canSee(tokenA, 'token:aaa')).toBe(true);
106
+ expect(canSee(tokenA, 'token:bbb')).toBe(false);
107
+ expect(canSee(tokenB, 'token:aaa')).toBe(false);
108
+ });
109
+ });
@@ -0,0 +1,137 @@
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
+ }
117
+
118
+ /**
119
+ * Tenant-isolation visibility check (4.0 · P3). Decides whether `principal`
120
+ * may see a record tagged with `createdBy`.
121
+ *
122
+ * - `local` (loopback / stdio solo / no-auth) → sees everything. This keeps
123
+ * solo dev's behaviour completely unchanged.
124
+ * - unowned data (`createdBy` null/undefined — legacy rows from before P1, or
125
+ * records the daemon never tagged) → visible to everyone (backward compat).
126
+ * - otherwise → visible only to the principal that created it.
127
+ *
128
+ * Note: in the current single-token / loopback reality the data creator
129
+ * (plugin / runtime client) and the querying agent share one principal, so
130
+ * this is exact. A full `project → agent` binding (creator ≠ consumer, once
131
+ * P6 splits write/read scopes) is deferred to P6.
132
+ */
133
+ export function canSee(principal: Principal, createdBy: string | null | undefined): boolean {
134
+ if (principal.kind === 'local') return true;
135
+ if (createdBy == null) return true;
136
+ return createdBy === principal.id;
137
+ }
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 { canSee, 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().
@@ -94,7 +102,7 @@ export function createMcpServer(bridge: IBridge, options: McpServerOptions = {})
94
102
  const leaderStore = (bridge as Bridge).store;
95
103
  if (leaderStore != null) {
96
104
  const memoryStore = bridge.getMemoryStore();
97
- registerStoreTools(server, leaderStore, memoryStore, bridge);
105
+ registerStoreTools(server, leaderStore, memoryStore, bridge, options.auth);
98
106
  } else if (bridge instanceof RemoteBridge) {
99
107
  registerRemoteStoreTools(server, bridge);
100
108
  }
@@ -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
  {
@@ -729,108 +737,106 @@ function registerTools(server: McpServer, bridge: IBridge): void {
729
737
  );
730
738
 
731
739
  // ─── tasks.* tools (user annotations submitted from page) ─────────────
732
- // TODO: temporarily hidden — re-enable when the report feature is ready.
733
- // To re-enable: remove the `if (false)` wrapper below and restore the block.
734
740
 
735
- /* eslint-disable no-constant-condition */
736
- if (false) {
737
- const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
741
+ const taskStatusEnum = z.enum(['pending', 'claimed', 'resolved', 'all']);
738
742
 
739
- server.registerTool(
740
- COMMAND.TASKS_PENDING,
741
- {
742
- description:
743
- 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
744
- inputSchema: {
745
- status: taskStatusEnum.optional(),
746
- limit: z.number().int().positive().optional(),
747
- },
748
- },
749
- async ({ status, limit }) => {
750
- const tasks = await bridge.listTasks({ status: status ?? 'pending', limit });
751
- const summary = tasks.map((t) => ({
752
- id: t.id,
753
- status: t.status,
754
- question: t.question,
755
- selector: t.selector,
756
- url: t.url,
757
- tabId: t.tabId,
758
- createdAt: t.createdAt,
759
- claimedAt: t.claimedAt,
760
- resolvedAt: t.resolvedAt,
761
- note: t.note,
762
- }));
763
- return ok({ count: summary.length, tasks: summary });
743
+ server.registerTool(
744
+ COMMAND.TASKS_PENDING,
745
+ {
746
+ description:
747
+ 'List user-submitted annotation tasks. Default `status="pending"`. Returns id/question/selector/url — call tasks.claim to fetch full element payload.',
748
+ inputSchema: {
749
+ status: taskStatusEnum.optional(),
750
+ limit: z.number().int().positive().optional(),
764
751
  },
765
- );
752
+ },
753
+ async ({ status, limit }, extra) => {
754
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
755
+ const tasks = (await bridge.listTasks({ status: status ?? 'pending', limit }))
756
+ .filter((t) => canSee(principal, t.createdBy));
757
+ const summary = tasks.map((t) => ({
758
+ id: t.id,
759
+ status: t.status,
760
+ question: t.question,
761
+ selector: t.selector,
762
+ url: t.url,
763
+ tabId: t.tabId,
764
+ createdAt: t.createdAt,
765
+ claimedAt: t.claimedAt,
766
+ resolvedAt: t.resolvedAt,
767
+ note: t.note,
768
+ }));
769
+ return ok({ count: summary.length, tasks: summary });
770
+ },
771
+ );
766
772
 
767
- server.registerTool(
768
- COMMAND.TASKS_CLAIM,
769
- {
770
- description:
771
- 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
772
- inputSchema: {
773
- taskId: z.string(),
774
- },
775
- },
776
- async ({ taskId }) => {
777
- const task = await bridge.claimTask(taskId);
778
- if (!task) {
779
- throw new Error(`tasks.claim: no task with id "${taskId}"`);
780
- }
781
- return ok(task);
773
+ server.registerTool(
774
+ COMMAND.TASKS_CLAIM,
775
+ {
776
+ description:
777
+ 'Claim a task by id. Marks it claimed, returns full payload (selector + element outerHTML + rect).',
778
+ inputSchema: {
779
+ taskId: z.string(),
782
780
  },
783
- );
781
+ },
782
+ async ({ taskId }, extra) => {
783
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
784
+ const task = await bridge.claimTask(taskId, principal);
785
+ if (!task) {
786
+ throw new Error(`tasks.claim: no task with id "${taskId}"`);
787
+ }
788
+ return ok(task);
789
+ },
790
+ );
784
791
 
785
- server.registerTool(
786
- COMMAND.TASKS_RESOLVE,
787
- {
788
- description:
789
- 'Mark a task as resolved with an optional note. Use after addressing the user request.',
790
- inputSchema: {
791
- taskId: z.string(),
792
- note: z.string().optional(),
793
- },
794
- },
795
- async ({ taskId, note }) => {
796
- const task = await bridge.resolveTask(taskId, note);
797
- if (!task) {
798
- throw new Error(`tasks.resolve: no task with id "${taskId}"`);
799
- }
800
- return ok({ ok: true, task });
792
+ server.registerTool(
793
+ COMMAND.TASKS_RESOLVE,
794
+ {
795
+ description:
796
+ 'Mark a task as resolved with an optional note. Use after addressing the user request.',
797
+ inputSchema: {
798
+ taskId: z.string(),
799
+ note: z.string().optional(),
801
800
  },
802
- );
801
+ },
802
+ async ({ taskId, note }, extra) => {
803
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
804
+ const task = await bridge.resolveTask(taskId, note, principal);
805
+ if (!task) {
806
+ throw new Error(`tasks.resolve: no task with id "${taskId}"`);
807
+ }
808
+ return ok({ ok: true, task });
809
+ },
810
+ );
803
811
 
804
- server.registerTool(
805
- 'tasks.get_attachment',
806
- {
807
- description:
808
- 'Return a task screenshot attachment as a vision-ready image block. ' +
809
- 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
810
- 'Compatible with Claude vision and GPT-4V.',
811
- inputSchema: {
812
- taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
813
- attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
814
- },
815
- },
816
- async ({ taskId, attachmentId }) => {
817
- const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
818
- if (!base64) {
819
- throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
820
- }
821
- return {
822
- content: [
823
- {
824
- type: 'image' as const,
825
- mimeType: 'image/png' as const,
826
- data: base64,
827
- },
828
- ],
829
- };
812
+ server.registerTool(
813
+ 'tasks.get_attachment',
814
+ {
815
+ description:
816
+ 'Return a task screenshot attachment as a vision-ready image block. ' +
817
+ 'Call after tasks.claim when the task summary includes an attachment pointer. ' +
818
+ 'Compatible with Claude vision and GPT-4V.',
819
+ inputSchema: {
820
+ taskId: z.string().describe('Task id (from tasks.pending or tasks.claim).'),
821
+ attachmentId: z.string().describe('Attachment id (from task.attachments[].id).'),
830
822
  },
831
- );
832
- }
833
- /* eslint-enable no-constant-condition */
823
+ },
824
+ async ({ taskId, attachmentId }) => {
825
+ const base64 = await bridge.getTaskAttachmentData(taskId, attachmentId);
826
+ if (!base64) {
827
+ throw new Error(`tasks.get_attachment: attachment not found (taskId=${taskId}, attachmentId=${attachmentId})`);
828
+ }
829
+ return {
830
+ content: [
831
+ {
832
+ type: 'image' as const,
833
+ mimeType: 'image/png' as const,
834
+ data: base64,
835
+ },
836
+ ],
837
+ };
838
+ },
839
+ );
834
840
  }
835
841
 
836
842
  // ─── Experimental tools (gated by HARNESS_FE_EXPERIMENTAL) ────────────────────
@@ -861,7 +867,7 @@ function registerExperimentalTools(server: McpServer, bridge: IBridge): void {
861
867
 
862
868
  // ─── Store tools (session history, timeline, memory) ──────────────────────────
863
869
 
864
- function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge): void {
870
+ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemoryStore, bridge: IBridge, auth?: AuthOptions): void {
865
871
  server.registerTool(
866
872
  'session.list',
867
873
  {
@@ -871,8 +877,11 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
871
877
  limit: z.number().int().positive().default(10).optional(),
872
878
  },
873
879
  },
874
- async ({ projectId, limit }) => {
875
- const sessions = store.listSessions({ projectId, limit: limit ?? 10 });
880
+ async ({ projectId, limit }, extra) => {
881
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
882
+ const sessions = store
883
+ .listSessions({ projectId, limit: limit ?? 10 })
884
+ .filter((s) => canSee(principal, s.createdBy));
876
885
  return ok(sessions);
877
886
  },
878
887
  );
@@ -959,11 +968,14 @@ function registerStoreTools(server: McpServer, store: IStore, memoryStore: IMemo
959
968
  description: 'List all projects with their most recent session info.',
960
969
  inputSchema: {},
961
970
  },
962
- async () => {
963
- const projects = store.listProjects();
971
+ async (_args, extra) => {
972
+ const principal = identifyPrincipal(extra.requestInfo?.headers, auth ?? {});
973
+ const projects = store.listProjects().filter((p) => canSee(principal, p.createdBy));
964
974
  const result = projects.map((p) => ({
965
975
  ...p,
966
- recentSessions: store.listSessions({ projectId: p.id, limit: 3 }),
976
+ recentSessions: store
977
+ .listSessions({ projectId: p.id, limit: 3 })
978
+ .filter((s) => canSee(principal, s.createdBy)),
967
979
  }));
968
980
  return ok(result);
969
981
  },
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
- const server = createMcpServer(bridge, { experimentalEnvVar: opts.experimentalEnvVar });
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,
@@ -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. */
@@ -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
+ });
@@ -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