@bevel-software/platform-core-backend 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-guide/access-control.md +49 -4
- package/agent-guide/html-views.md +76 -2
- package/dist/core/catalog-revision.js +3 -3
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +4 -2
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +1 -5
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +66 -6
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/audit/audit.contract.d.ts +52 -8
- package/dist/modules/audit/audit.contract.d.ts.map +1 -1
- package/dist/modules/audit/audit.contract.js +37 -1
- package/dist/modules/audit/audit.contract.js.map +1 -1
- package/dist/modules/audit/audit.routes.d.ts +1 -1
- package/dist/modules/audit/audit.routes.d.ts.map +1 -1
- package/dist/modules/audit/audit.routes.js +15 -13
- package/dist/modules/audit/audit.routes.js.map +1 -1
- package/dist/modules/audit/request-audit.d.ts +21 -10
- package/dist/modules/audit/request-audit.d.ts.map +1 -1
- package/dist/modules/audit/request-audit.js +26 -10
- package/dist/modules/audit/request-audit.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +22 -3
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +15 -6
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +1 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +1 -0
- package/dist/modules/kb-fs/index.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +3 -12
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +11 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +14 -0
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +14 -0
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +4 -0
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +5 -0
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +67 -0
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +107 -27
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +257 -50
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/mcp/request-context.d.ts +25 -0
- package/dist/modules/mcp/request-context.d.ts.map +1 -0
- package/dist/modules/mcp/request-context.js +11 -0
- package/dist/modules/mcp/request-context.js.map +1 -0
- package/dist/modules/mcp/shared-definitions.d.ts +56 -0
- package/dist/modules/mcp/shared-definitions.d.ts.map +1 -0
- package/dist/modules/mcp/shared-definitions.js +117 -0
- package/dist/modules/mcp/shared-definitions.js.map +1 -0
- package/dist/modules/mcp/surface-cache.d.ts +70 -0
- package/dist/modules/mcp/surface-cache.d.ts.map +1 -0
- package/dist/modules/mcp/surface-cache.js +104 -0
- package/dist/modules/mcp/surface-cache.js.map +1 -0
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +33 -12
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.d.ts +8 -0
- package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool.contract.js.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js +3 -14
- package/dist/modules/workflow/agent-tools/change-request-read.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +18 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +453 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +113 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +470 -106
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/read-on-branch.d.ts +35 -0
- package/dist/modules/workflow/read-on-branch.d.ts.map +1 -0
- package/dist/modules/workflow/read-on-branch.js +58 -0
- package/dist/modules/workflow/read-on-branch.js.map +1 -0
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +22 -41
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +48 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +163 -16
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +2 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +4 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/shared/git.contract.d.ts +7 -0
- package/dist/shared/git.contract.d.ts.map +1 -1
- package/dist/shared/git.contract.js +9 -0
- package/dist/shared/git.contract.js.map +1 -1
- package/dist/shared/no-link-below.d.ts +10 -0
- package/dist/shared/no-link-below.d.ts.map +1 -0
- package/dist/shared/no-link-below.js +30 -0
- package/dist/shared/no-link-below.js.map +1 -0
- package/migrations/0018_agent_events_page.sql +11 -0
- package/migrations/meta/0018_snapshot.json +2429 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +3 -3
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +2 -1
- package/src/core/create-core-services.ts +4 -2
- package/src/modules/access/access-control.service.ts +1 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/audit/__tests__/agent-audit.service.test.ts +78 -1
- package/src/modules/audit/__tests__/audit.routes.test.ts +43 -5
- package/src/modules/audit/agent-audit.service.ts +66 -6
- package/src/modules/audit/audit.contract.ts +62 -10
- package/src/modules/audit/audit.routes.ts +14 -12
- package/src/modules/audit/request-audit.ts +28 -13
- package/src/modules/database/core-schema.ts +15 -6
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/index.ts +1 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/mcp/__tests__/mcp-routes-harness.ts +7 -1
- package/src/modules/mcp/__tests__/mcp.routes.page.test.ts +115 -0
- package/src/modules/mcp/__tests__/mcp.surface-cache.test.ts +521 -0
- package/src/modules/mcp/__tests__/shared-definitions.test.ts +28 -0
- package/src/modules/mcp/__tests__/surface-cache.test.ts +127 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +21 -0
- package/src/modules/mcp/mcp.routes.ts +68 -0
- package/src/modules/mcp/mcp.service.ts +315 -51
- package/src/modules/mcp/request-context.ts +34 -0
- package/src/modules/mcp/shared-definitions.ts +121 -0
- package/src/modules/mcp/surface-cache.ts +154 -0
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool.contract.ts +8 -0
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +23 -12
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +250 -4
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +464 -4
- package/src/modules/workflow/agent-tools/change-request-read.tools.ts +4 -22
- package/src/modules/workflow/agent-tools/workflow.tools.ts +462 -1
- package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +76 -11
- package/src/modules/workflow/git/__tests__/git.service.mergeIntoBranch.test.ts +721 -0
- package/src/modules/workflow/git/git.service.ts +537 -106
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- package/src/modules/workspace/index.ts +11 -0
- package/src/shared/__tests__/no-link-below.test.ts +45 -0
- package/src/shared/git.contract.ts +10 -0
- package/src/shared/no-link-below.ts +29 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CodeModeUtcpClient } from '@utcp/code-mode';
|
|
2
2
|
import { logger } from '../../shared/logging.js';
|
|
3
|
-
import type { AgentEventOutcome,
|
|
3
|
+
import type { AgentEventOutcome, IAgentEventRecorder, RecordedPrincipal } from './audit.contract.js';
|
|
4
4
|
import {
|
|
5
5
|
classifyToolCall,
|
|
6
6
|
skillReadPath,
|
|
@@ -13,11 +13,11 @@ const log = logger('audit');
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* The Audit log's view of ONE MCP request: who is calling (the user and the
|
|
16
|
-
* key
|
|
17
|
-
* they call. The MCP proxy creates one per request that has a principal
|
|
18
|
-
* routes every call through it; a request with none (a browser JWT
|
|
19
|
-
* internal token minted before connections existed) gets no
|
|
20
|
-
* records nothing.
|
|
16
|
+
* key, agent connection or page the request arrived through), and how to name
|
|
17
|
+
* what they call. The MCP proxy creates one per request that has a principal
|
|
18
|
+
* and routes every call through it; a request with none (a browser JWT that
|
|
19
|
+
* names no page, an internal token minted before connections existed) gets no
|
|
20
|
+
* instance and records nothing.
|
|
21
21
|
*
|
|
22
22
|
* Holds nothing that outlives the request. The skill catalog it may need to
|
|
23
23
|
* tell a skill read from a file read is fetched at most once, and only when
|
|
@@ -29,7 +29,7 @@ export class RequestAudit {
|
|
|
29
29
|
constructor(
|
|
30
30
|
private readonly recorder: IAgentEventRecorder,
|
|
31
31
|
private readonly userId: string,
|
|
32
|
-
private readonly principal:
|
|
32
|
+
private readonly principal: RecordedPrincipal,
|
|
33
33
|
private readonly ctx: {
|
|
34
34
|
kbManualName: string;
|
|
35
35
|
/** Read at classification time — the surface (and its catalog names) is built lazily. */
|
|
@@ -93,9 +93,13 @@ export class RequestAudit {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
/**
|
|
97
|
+
* A call refused before it ran: `denied` when the caller's sign-in for its
|
|
98
|
+
* manual is missing, `refused` when the person viewing a page answered its
|
|
99
|
+
* request to write with Deny.
|
|
100
|
+
*/
|
|
101
|
+
async denied(utcpName: string, args: Record<string, unknown>, outcome: 'denied' | 'refused' = 'denied'): Promise<void> {
|
|
102
|
+
this.emit(await this.classify(utcpName, args), outcome, null);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -106,11 +110,22 @@ export class RequestAudit {
|
|
|
106
110
|
* The direct MCP path (`callToolStreaming`) is deliberately NOT wrapped —
|
|
107
111
|
* the proxy's tool handler records those itself, with the result's
|
|
108
112
|
* `isError` in hand.
|
|
113
|
+
*
|
|
114
|
+
* Installed ONCE, when the client is built, because the client is shared by
|
|
115
|
+
* every request of its person: `current` names the request a call belongs
|
|
116
|
+
* to, at call time, and a call outside any request (or in one attributed to
|
|
117
|
+
* nothing) is recorded nowhere. Rebinding the client per request instead
|
|
118
|
+
* would hand one request's calls to another's audit, and stack a wrapper
|
|
119
|
+
* per request.
|
|
109
120
|
*/
|
|
110
|
-
instrumentChainCalls(client: CodeModeUtcpClient): void {
|
|
121
|
+
static instrumentChainCalls(client: CodeModeUtcpClient, current: () => RequestAudit | null | undefined): void {
|
|
111
122
|
const callTool = client.callTool.bind(client);
|
|
112
|
-
client.callTool = (toolName: string, toolArgs: Record<string, unknown>) =>
|
|
113
|
-
|
|
123
|
+
client.callTool = (toolName: string, toolArgs: Record<string, unknown>) => {
|
|
124
|
+
const audit = current();
|
|
125
|
+
return audit
|
|
126
|
+
? audit.call(toolName, toolArgs, () => callTool(toolName, toolArgs), isErrorShaped)
|
|
127
|
+
: callTool(toolName, toolArgs);
|
|
128
|
+
};
|
|
114
129
|
}
|
|
115
130
|
|
|
116
131
|
private skillCatalog(): Promise<readonly SkillFolder[] | null> {
|
|
@@ -589,22 +589,30 @@ export const oauthTokens = pgTable('oauth_tokens', {
|
|
|
589
589
|
* which agent, when" — not "what was said".
|
|
590
590
|
*
|
|
591
591
|
* Exactly one principal per row (the CHECK): the connection key or the agent
|
|
592
|
-
* connection the call arrived through
|
|
593
|
-
*
|
|
594
|
-
*
|
|
592
|
+
* connection the call arrived through, or the knowledge-base page whose
|
|
593
|
+
* script made the call as the person viewing it (`page_path`). Key and
|
|
594
|
+
* connection cascade, so deleting a key for good, revoking-then-erasing a
|
|
595
|
+
* person, or dropping a connection takes its events along — the same contract
|
|
596
|
+
* the LLM-usage rows already keep; a page's events go with the person.
|
|
595
597
|
*/
|
|
596
598
|
export const agentEvents = pgTable('agent_events', {
|
|
597
599
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
598
600
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
599
601
|
keyId: uuid('key_id').references(() => externalApiKeys.id, { onDelete: 'cascade' }),
|
|
600
602
|
connectionId: uuid('connection_id').references(() => agentConnections.id, { onDelete: 'cascade' }),
|
|
603
|
+
/** The knowledge-base path of the HTML page that made the call, for a page's calls; null otherwise. */
|
|
604
|
+
pagePath: text('page_path'),
|
|
601
605
|
/** `capability` | `tool` | `skill`. */
|
|
602
606
|
kind: text('kind').notNull(),
|
|
603
607
|
/** The MCP server / `.tool` manual (catalog name) for a tool, the skill folder for a skill; null for a capability. */
|
|
604
608
|
manual: text('manual'),
|
|
605
609
|
/** The tool's bare name, or the skill's name. */
|
|
606
610
|
name: text('name').notNull(),
|
|
607
|
-
/**
|
|
611
|
+
/**
|
|
612
|
+
* `ok` | `error` | `denied` | `refused`. Denied: the caller's sign-in for the
|
|
613
|
+
* tool was missing, so nothing ran. Refused: the viewer answered a page's
|
|
614
|
+
* request to write with Deny, so nothing ran.
|
|
615
|
+
*/
|
|
608
616
|
outcome: text('outcome').notNull(),
|
|
609
617
|
durationMs: integer('duration_ms'),
|
|
610
618
|
at: timestamp('at').defaultNow().notNull(),
|
|
@@ -612,14 +620,15 @@ export const agentEvents = pgTable('agent_events', {
|
|
|
612
620
|
// The two read paths: one principal's events, newest first.
|
|
613
621
|
byKey: index('agent_events_by_key').on(t.keyId, t.at),
|
|
614
622
|
byConnection: index('agent_events_by_connection').on(t.connectionId, t.at),
|
|
623
|
+
byPage: index('agent_events_by_page').on(t.userId, t.pagePath, t.at),
|
|
615
624
|
// The retention prune, and per-user counts.
|
|
616
625
|
byAt: index('agent_events_by_at').on(t.at),
|
|
617
626
|
byUser: index('agent_events_by_user').on(t.userId),
|
|
618
627
|
kindCheck: check('agent_events_kind', sql`${t.kind} IN ('capability', 'tool', 'skill')`),
|
|
619
|
-
outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied')`),
|
|
628
|
+
outcomeCheck: check('agent_events_outcome', sql`${t.outcome} IN ('ok', 'error', 'denied', 'refused')`),
|
|
620
629
|
principalCheck: check(
|
|
621
630
|
'agent_events_principal',
|
|
622
|
-
sql`(${t.keyId}
|
|
631
|
+
sql`num_nonnulls(${t.keyId}, ${t.connectionId}, ${t.pagePath}) = 1`,
|
|
623
632
|
),
|
|
624
633
|
}));
|
|
625
634
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { WorkspaceIdMutex, WorkspaceMutex } from '../mutex.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `WorkspaceIdMutex`: a workspace id in either spelling — a route's decoded
|
|
6
|
+
* `alice/feature`, a tool's `alice%2Ffeature` — takes the one key.
|
|
7
|
+
*/
|
|
8
|
+
describe('WorkspaceIdMutex', () => {
|
|
9
|
+
/** A task that signals when it starts and ends only when released. */
|
|
10
|
+
const held = () => {
|
|
11
|
+
let release: () => void = () => {};
|
|
12
|
+
let started: () => void = () => {};
|
|
13
|
+
const begun = new Promise<void>((r) => { started = r; });
|
|
14
|
+
const task = () =>
|
|
15
|
+
new Promise<void>((resolve) => {
|
|
16
|
+
release = resolve;
|
|
17
|
+
started();
|
|
18
|
+
});
|
|
19
|
+
return { task, begun, release: () => release() };
|
|
20
|
+
};
|
|
21
|
+
const settled = () => new Promise<void>((r) => setImmediate(r));
|
|
22
|
+
|
|
23
|
+
it('queues the decoded and the encoded spelling of one workspace on the same key', async () => {
|
|
24
|
+
const mutex = new WorkspaceIdMutex();
|
|
25
|
+
const first = held();
|
|
26
|
+
const order: string[] = [];
|
|
27
|
+
const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
|
|
28
|
+
await first.begun;
|
|
29
|
+
const b = mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
|
|
30
|
+
await settled();
|
|
31
|
+
// The encoded spelling waits on the decoded one's reservation: nothing
|
|
32
|
+
// has run until the first is released; then both do.
|
|
33
|
+
expect(order).toEqual([]);
|
|
34
|
+
first.release();
|
|
35
|
+
await Promise.all([a, b]);
|
|
36
|
+
expect([...order].sort()).toEqual(['decoded', 'encoded']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is what the plain mutex is not: two spellings, two keys, no wait', async () => {
|
|
40
|
+
const mutex = new WorkspaceMutex();
|
|
41
|
+
const first = held();
|
|
42
|
+
const order: string[] = [];
|
|
43
|
+
const a = mutex.run('alice/feature', first.task).then(() => { order.push('decoded'); });
|
|
44
|
+
await first.begun;
|
|
45
|
+
await mutex.run('alice%2Ffeature', async () => { order.push('encoded'); });
|
|
46
|
+
expect(order).toEqual(['encoded']);
|
|
47
|
+
first.release();
|
|
48
|
+
await a;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('leaves a protected branch (no slash, one spelling) and a malformed id as they are', async () => {
|
|
52
|
+
const mutex = new WorkspaceIdMutex();
|
|
53
|
+
const seen: string[] = [];
|
|
54
|
+
await mutex.runAll(['main', 'bad%'], async () => { seen.push('ran'); });
|
|
55
|
+
expect(seen).toEqual(['ran']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
* workspace needs only the shape of the gate every write passes first.
|
|
5
5
|
*/
|
|
6
6
|
export type { WriteValidator } from './locking-filesystem.js';
|
|
7
|
+
export { WorkspaceMutex, WorkspaceIdMutex } from './mutex.js';
|
|
7
8
|
export { readRefSha, readRevSha, type RefFileAnswer } from './ref-files.js';
|
|
8
9
|
export { gitDirOf, readGitfile } from './git-dir.js';
|
|
@@ -52,6 +52,7 @@ import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
|
|
|
52
52
|
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
53
53
|
import { isAbsence } from '../../shared/fs.contract.js';
|
|
54
54
|
import { DestinationTakenError, lstatOrNull, renameNoReplace, takenError } from '../../shared/rename-no-replace.js';
|
|
55
|
+
import { linkedAncestor } from '../../shared/no-link-below.js';
|
|
55
56
|
import { assertInsideRepo } from './repo-path.js';
|
|
56
57
|
import { GitGuardedFilesystem } from './git-guarded-filesystem.js';
|
|
57
58
|
import type { CreationGrantPlan, ICreatorAccess } from '../access-model/creator.js';
|
|
@@ -644,18 +645,8 @@ export class LockingFilesystem extends GitGuardedFilesystem {
|
|
|
644
645
|
private async assertNoLinkBelowBase(inputPath: string): Promise<void> {
|
|
645
646
|
const absolute = this.resolveAbsolutePath(inputPath);
|
|
646
647
|
if (!absolute) return;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
const ancestors: string[] = [];
|
|
650
|
-
while (current.startsWith(base) && current !== base) {
|
|
651
|
-
ancestors.push(current);
|
|
652
|
-
current = path.dirname(current);
|
|
653
|
-
}
|
|
654
|
-
for (const ancestor of ancestors) {
|
|
655
|
-
const stat = await lstatOrNull(ancestor);
|
|
656
|
-
if (stat?.isSymbolicLink()) {
|
|
657
|
-
throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
|
|
658
|
-
}
|
|
648
|
+
if ((await linkedAncestor(this.basePath, absolute)) !== null) {
|
|
649
|
+
throw new Error(`"${inputPath}" is reached through a link, so nothing was moved.`);
|
|
659
650
|
}
|
|
660
651
|
}
|
|
661
652
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalWorkspaceId } from '../../shared/workspace-id.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Serializes async operations by key. Two `run(k, ...)` calls with the same key
|
|
3
5
|
* execute sequentially in call order; different keys run independently.
|
|
@@ -65,3 +67,17 @@ export class WorkspaceMutex {
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A `WorkspaceMutex` whose keys are WORKSPACE IDS, taken in their canonical
|
|
73
|
+
* spelling: a route's Express-decoded `:id` (`alice/feature`) and a tool's
|
|
74
|
+
* `alice%2Ffeature` name the same clone and must queue on the same key — two
|
|
75
|
+
* spellings would let a route's read run beside a tool's merge of the same
|
|
76
|
+
* checkout. `canonicalWorkspaceId` collapses both to the encoded form every
|
|
77
|
+
* other keyed store (the file locks, the pending commits) already uses.
|
|
78
|
+
*/
|
|
79
|
+
export class WorkspaceIdMutex extends WorkspaceMutex {
|
|
80
|
+
override async runAll<T>(keys: string[], fn: () => Promise<T>): Promise<T> {
|
|
81
|
+
return super.runAll(keys.map(canonicalWorkspaceId), fn);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Server as HttpServer } from 'node:http';
|
|
|
2
2
|
import type { RequestHandler } from 'express';
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import { createMcpRoutes } from '../mcp.routes.js';
|
|
5
|
+
import type { McpCallerKind } from '../mcp-auth.middleware.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The shared mount for every `createMcpRoutes` suite.
|
|
@@ -19,7 +20,8 @@ import { createMcpRoutes } from '../mcp.routes.js';
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* Auth stand-in binding the user named by `x-test-user`, defaulting to user-A
|
|
23
|
+
* Auth stand-in binding the user named by `x-test-user`, defaulting to user-A,
|
|
24
|
+
* and the credential kind named by `x-test-kind`, when one is.
|
|
23
25
|
*
|
|
24
26
|
* The header lets a suite act as a second user (the cross-user checks need
|
|
25
27
|
* one); suites that never send it see the single fixed user their assertions
|
|
@@ -27,6 +29,10 @@ import { createMcpRoutes } from '../mcp.routes.js';
|
|
|
27
29
|
*/
|
|
28
30
|
export const fakeAuth: RequestHandler = (req, _res, next) => {
|
|
29
31
|
req.userId = (req.headers['x-test-user'] as string | undefined) ?? 'user-A';
|
|
32
|
+
// The credential kind the real middleware would have recognised, when a
|
|
33
|
+
// suite needs one (the page header is a session's alone).
|
|
34
|
+
const kind = req.headers['x-test-kind'] as McpCallerKind | undefined;
|
|
35
|
+
if (kind) req.mcpCallerKind = kind;
|
|
30
36
|
next();
|
|
31
37
|
};
|
|
32
38
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import type { McpCaller } from '../mcp.service.js';
|
|
5
|
+
import { closeMountedRoutes, mountMcpRoutes } from './mcp-routes-harness.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The page a call comes from: an HTML page of the knowledge base whose script
|
|
9
|
+
* calls the viewer's tools through the app. The app names it in
|
|
10
|
+
* `X-Bevel-Page`, and only the app — a session — may: a key, an OAuth token or
|
|
11
|
+
* an internal token belongs to an agent with an Audit log row of its own.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await closeMountedRoutes();
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function makeMcpService() {
|
|
20
|
+
const callers: McpCaller[] = [];
|
|
21
|
+
return {
|
|
22
|
+
callers,
|
|
23
|
+
createRequestServer: vi.fn(async (caller: McpCaller) => {
|
|
24
|
+
callers.push(caller);
|
|
25
|
+
const server = new Server({ name: 'stub', version: '0' }, { capabilities: { tools: {} } });
|
|
26
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
|
|
27
|
+
return server;
|
|
28
|
+
}),
|
|
29
|
+
recordPageRefusal: vi.fn(async () => {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} };
|
|
34
|
+
const PAGE = 'Dashboards/Road map — Q4.html';
|
|
35
|
+
|
|
36
|
+
function post(baseUrl: string, path: string, body: unknown, headers: Record<string, string>) {
|
|
37
|
+
return fetch(`${baseUrl}/api${path}`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...headers },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('POST /mcp with X-Bevel-Page', () => {
|
|
45
|
+
it("hands a session's page to the service, decoded", async () => {
|
|
46
|
+
const mcpService = makeMcpService();
|
|
47
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
48
|
+
const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent(PAGE) });
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
expect(mcpService.callers[0]).toMatchObject({ userId: 'user-A', page: PAGE });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each(['key', 'oauth', 'internal'])('refuses the header from a %s caller, building nothing', async (kind) => {
|
|
54
|
+
const mcpService = makeMcpService();
|
|
55
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
56
|
+
const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': kind, 'x-bevel-page': encodeURIComponent(PAGE) });
|
|
57
|
+
expect(res.status).toBe(400);
|
|
58
|
+
expect(((await res.json()) as { error: { message: string } }).error.message).toContain('X-Bevel-Page');
|
|
59
|
+
expect(mcpService.createRequestServer).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('refuses a page that is not a path', async () => {
|
|
63
|
+
const mcpService = makeMcpService();
|
|
64
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
65
|
+
const res = await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session', 'x-bevel-page': encodeURIComponent('a\nb') });
|
|
66
|
+
expect(res.status).toBe(400);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('names no page when the header is absent, as before', async () => {
|
|
70
|
+
const mcpService = makeMcpService();
|
|
71
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
72
|
+
await post(baseUrl, '/mcp', TOOLS_LIST, { 'x-test-kind': 'session' });
|
|
73
|
+
expect(mcpService.callers[0]).not.toHaveProperty('page');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('POST /mcp/page-refusals', () => {
|
|
78
|
+
it("records a session's refused call under its page", async () => {
|
|
79
|
+
const mcpService = makeMcpService();
|
|
80
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
81
|
+
const res = await post(
|
|
82
|
+
baseUrl,
|
|
83
|
+
'/mcp/page-refusals',
|
|
84
|
+
{ page: PAGE, tool: 'edit_file', args: { body: { path: 'a.md' } } },
|
|
85
|
+
{ 'x-test-kind': 'session', Authorization: 'Bearer eyJ.session' },
|
|
86
|
+
);
|
|
87
|
+
expect(res.status).toBe(204);
|
|
88
|
+
expect(mcpService.recordPageRefusal).toHaveBeenCalledWith(
|
|
89
|
+
{ userId: 'user-A', bearer: 'eyJ.session' },
|
|
90
|
+
PAGE,
|
|
91
|
+
'edit_file',
|
|
92
|
+
{ body: { path: 'a.md' } },
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('is the app\'s alone: an agent credential is refused', async () => {
|
|
97
|
+
const mcpService = makeMcpService();
|
|
98
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
99
|
+
const res = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool: 'edit_file' }, { 'x-test-kind': 'key' });
|
|
100
|
+
expect(res.status).toBe(403);
|
|
101
|
+
expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('wants a page and a tool', async () => {
|
|
105
|
+
const mcpService = makeMcpService();
|
|
106
|
+
const baseUrl = await mountMcpRoutes({ mcpService });
|
|
107
|
+
const res = await post(baseUrl, '/mcp/page-refusals', { tool: 'edit_file' }, { 'x-test-kind': 'session' });
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
for (const tool of [undefined, '']) {
|
|
110
|
+
const noTool = await post(baseUrl, '/mcp/page-refusals', { page: PAGE, tool }, { 'x-test-kind': 'session' });
|
|
111
|
+
expect(noTool.status).toBe(400);
|
|
112
|
+
}
|
|
113
|
+
expect(mcpService.recordPageRefusal).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|