@bevel-software/platform-core-backend 0.29.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/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- 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 +3 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +35 -5
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +8 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +4 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +32 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +135 -17
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +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/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +3 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -1
- package/dist/modules/kb-fs/index.js +3 -1
- 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/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- 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/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- 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-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +10 -2
- 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 +142 -4
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +645 -128
- 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-hooks.d.ts +1 -1
- 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/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +9 -0
- package/dist/modules/workspace/index.d.ts.map +1 -1
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -1
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +162 -336
- package/dist/modules/workspace/workspace.tools.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/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- 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/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/lifecycle.test.ts +16 -0
- package/src/core/catalog-revision.ts +3 -3
- package/src/core/create-core-server.ts +3 -2
- package/src/core/create-core-services.ts +39 -4
- package/src/core/lifecycle.ts +10 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +144 -4
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +138 -15
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +22 -1
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -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/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/mutex.canonical.test.ts +57 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +3 -0
- package/src/modules/kb-fs/locking-filesystem.ts +3 -12
- package/src/modules/kb-fs/mutex.ts +16 -0
- package/src/modules/kb-fs/ref-files.ts +279 -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/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/tool-context.ts +37 -18
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +10 -2
- package/src/modules/tool-registry/__tests__/own-tool-schemas.test.ts +1 -1
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +7 -2
- 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.changedPathsMemo.test.ts +175 -0
- 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/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +719 -126
- package/src/modules/workflow/read-on-branch.ts +79 -0
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +21 -39
- package/src/modules/workflow/workflow.service.ts +203 -16
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +153 -298
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +23 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +179 -365
- 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
- package/src/test-setup.ts +5 -0
|
@@ -5,6 +5,7 @@ import { createAuditRoutes } from '../audit.routes.js';
|
|
|
5
5
|
import {
|
|
6
6
|
AuditPrincipalNotFoundError,
|
|
7
7
|
InvalidCursorError,
|
|
8
|
+
pagePrincipalId,
|
|
8
9
|
type AuditPrincipal,
|
|
9
10
|
type IAgentAuditService,
|
|
10
11
|
} from '../audit.contract.js';
|
|
@@ -31,7 +32,9 @@ const ALICE_KEY: AuditPrincipal = {
|
|
|
31
32
|
};
|
|
32
33
|
|
|
33
34
|
/** Who owns what, as the fake service answers `ownerOf`. */
|
|
34
|
-
|
|
35
|
+
/** A page Alice's views have called tools from; its id names a person by uuid, so it is minted for one. */
|
|
36
|
+
const ALICE_PAGE_ID = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Roadmap.html');
|
|
37
|
+
const OWNERS: Record<string, string> = { [KEY_ID]: ALICE.id, [AGENT_ID]: BOB.id, [ALICE_AGENT_ID]: ALICE.id, [ALICE_PAGE_ID]: ALICE.id };
|
|
35
38
|
|
|
36
39
|
const audit = {
|
|
37
40
|
listPrincipals: vi.fn(async () => [ALICE_KEY]),
|
|
@@ -103,6 +106,24 @@ describe('audit routes — events', () => {
|
|
|
103
106
|
expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'key', id: KEY_ID }, { before: '123.e-9', limit: 200 });
|
|
104
107
|
});
|
|
105
108
|
|
|
109
|
+
it("serves a page's events to its viewer, and 404s an id that names no page", async () => {
|
|
110
|
+
const base = await listen(makeApp({ admin: false }));
|
|
111
|
+
const res = await fetch(`${base}/api/audit/principals/page/${ALICE_PAGE_ID}/events`);
|
|
112
|
+
expect(res.status).toBe(200);
|
|
113
|
+
expect(audit.listEvents).toHaveBeenCalledWith({ kind: 'page', id: ALICE_PAGE_ID }, { before: undefined, limit: 50 });
|
|
114
|
+
const notAPage = await fetch(`${base}/api/audit/principals/page/not-a-page/events`);
|
|
115
|
+
expect(notAPage.status).toBe(404);
|
|
116
|
+
expect(await notAPage.json()).toEqual({ error: 'No such agent, key or page' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('404s a page id whose path carries a line break of any kind', async () => {
|
|
120
|
+
const base = await listen(makeApp({ admin: false }));
|
|
121
|
+
for (const brk of ['\n', '\r', '\u0085', '\u2028', '\u2029']) {
|
|
122
|
+
const id = pagePrincipalId('33333333-4444-4555-8666-777777777777', `Dashboards/Road${brk}map.html`);
|
|
123
|
+
expect((await fetch(`${base}/api/audit/principals/page/${id}/events`)).status).toBe(404);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
106
127
|
it('answers a cursor the service does not recognise with 400, not 500', async () => {
|
|
107
128
|
const base = await listen(makeApp({ admin: false }));
|
|
108
129
|
audit.listEvents.mockRejectedValueOnce(new InvalidCursorError());
|
|
@@ -110,9 +131,23 @@ describe('audit routes — events', () => {
|
|
|
110
131
|
expect(res.status).toBe(400);
|
|
111
132
|
});
|
|
112
133
|
|
|
113
|
-
it("refuses another person's principal to a member, and serves it to an admin", async () => {
|
|
114
|
-
const
|
|
115
|
-
|
|
134
|
+
it("refuses another person's principal to a member exactly as one that does not exist, and serves it to an admin", async () => {
|
|
135
|
+
const asBob = await listen(makeApp({ admin: false, as: BOB }));
|
|
136
|
+
const missingPage = pagePrincipalId('33333333-4444-4555-8666-777777777777', 'Dashboards/Nothing.html');
|
|
137
|
+
const refusals = [];
|
|
138
|
+
const missing = '00000000-0000-4000-8000-000000000000';
|
|
139
|
+
for (const path of [
|
|
140
|
+
`page/${ALICE_PAGE_ID}`,
|
|
141
|
+
`page/${missingPage}`,
|
|
142
|
+
`key/${KEY_ID}`,
|
|
143
|
+
`key/${missing}`,
|
|
144
|
+
`agent/${ALICE_AGENT_ID}`,
|
|
145
|
+
`agent/${missing}`,
|
|
146
|
+
]) {
|
|
147
|
+
const res = await fetch(`${asBob}/api/audit/principals/${path}/events`);
|
|
148
|
+
refusals.push({ status: res.status, body: await res.json() });
|
|
149
|
+
}
|
|
150
|
+
expect(refusals).toEqual(Array(6).fill({ status: 404, body: { error: 'No such agent, key or page' } }));
|
|
116
151
|
expect(audit.listEvents).not.toHaveBeenCalled();
|
|
117
152
|
server.close();
|
|
118
153
|
|
|
@@ -166,7 +201,10 @@ describe('audit routes — revoke an agent', () => {
|
|
|
166
201
|
it('404s when the connection vanished between the ownership check and the revoke', async () => {
|
|
167
202
|
const base = await listen(makeApp({ admin: false, as: BOB }));
|
|
168
203
|
audit.revokeConnection.mockRejectedValueOnce(new AuditPrincipalNotFoundError());
|
|
169
|
-
|
|
204
|
+
const res = await fetch(`${base}/api/audit/agents/${AGENT_ID}`, { method: 'DELETE' });
|
|
205
|
+
expect(res.status).toBe(404);
|
|
206
|
+
// Only an agent revoke throws it, so it names an agent, as the route's other 404s do.
|
|
207
|
+
expect(await res.json()).toEqual({ error: 'No such agent' });
|
|
170
208
|
});
|
|
171
209
|
});
|
|
172
210
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, count, desc, eq, isNotNull, isNull, lt, or, type SQL } from 'drizzle-orm';
|
|
1
|
+
import { and, count, desc, eq, isNotNull, isNull, lt, max, min, or, type SQL } from 'drizzle-orm';
|
|
2
2
|
import { logger } from '../../shared/logging.js';
|
|
3
3
|
import type { Database } from '../database/connection.js';
|
|
4
4
|
import { agentConnections, agentEvents, externalApiKeys, oauthTokens, users } from '../database/schema.js';
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
AuditPrincipalNotFoundError,
|
|
8
8
|
InvalidCursorError,
|
|
9
9
|
isUuid,
|
|
10
|
+
pagePrincipalId,
|
|
11
|
+
parsePagePrincipalId,
|
|
10
12
|
parseRetentionWindow,
|
|
11
13
|
type AgentEventInput,
|
|
12
14
|
type AgentEventPage,
|
|
@@ -59,6 +61,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
59
61
|
userId: event.userId,
|
|
60
62
|
keyId: event.principal.kind === 'key' ? event.principal.id : null,
|
|
61
63
|
connectionId: event.principal.kind === 'agent' ? event.principal.id : null,
|
|
64
|
+
pagePath: event.principal.kind === 'page' ? event.principal.path : null,
|
|
62
65
|
kind: event.kind,
|
|
63
66
|
manual: event.manual,
|
|
64
67
|
name: event.name,
|
|
@@ -93,7 +96,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
93
96
|
|
|
94
97
|
async listPrincipals(scope: AuditScope): Promise<AuditPrincipal[]> {
|
|
95
98
|
const mine = scope === 'all' ? null : scope.userId;
|
|
96
|
-
const [keyRows, connectionRows, countRows] = await Promise.all([
|
|
99
|
+
const [keyRows, connectionRows, countRows, pageRows] = await Promise.all([
|
|
97
100
|
mine === null ? this.keys.listForDeployment() : this.keysForUser(mine),
|
|
98
101
|
this.db
|
|
99
102
|
.select({
|
|
@@ -111,6 +114,22 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
111
114
|
.from(agentEvents)
|
|
112
115
|
.where(mine === null ? undefined : eq(agentEvents.userId, mine))
|
|
113
116
|
.groupBy(agentEvents.keyId, agentEvents.connectionId),
|
|
117
|
+
// A page has no row of its own: it is every event recorded under one
|
|
118
|
+
// person and one path, so its "created" is its first call and its
|
|
119
|
+
// "last used" its latest.
|
|
120
|
+
this.db
|
|
121
|
+
.select({
|
|
122
|
+
userId: agentEvents.userId,
|
|
123
|
+
pagePath: agentEvents.pagePath,
|
|
124
|
+
n: count(),
|
|
125
|
+
first: min(agentEvents.at),
|
|
126
|
+
last: max(agentEvents.at),
|
|
127
|
+
user: { id: users.id, email: users.email, name: users.name },
|
|
128
|
+
})
|
|
129
|
+
.from(agentEvents)
|
|
130
|
+
.innerJoin(users, eq(agentEvents.userId, users.id))
|
|
131
|
+
.where(and(isNotNull(agentEvents.pagePath), mine === null ? undefined : eq(agentEvents.userId, mine)))
|
|
132
|
+
.groupBy(agentEvents.userId, agentEvents.pagePath, users.id, users.email, users.name),
|
|
114
133
|
]);
|
|
115
134
|
|
|
116
135
|
const counts = new Map<string, number>();
|
|
@@ -144,6 +163,24 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
144
163
|
eventCount: counts.get(c.id) ?? 0,
|
|
145
164
|
user,
|
|
146
165
|
})),
|
|
166
|
+
...pageRows.flatMap((r) =>
|
|
167
|
+
r.pagePath
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
kind: 'page' as const,
|
|
171
|
+
id: pagePrincipalId(r.userId, r.pagePath),
|
|
172
|
+
label: r.pagePath,
|
|
173
|
+
createdAt: toMs(r.first),
|
|
174
|
+
lastUsedAt: r.last ? toMs(r.last) : null,
|
|
175
|
+
// A page cannot be revoked: it acts only while its viewer has it open.
|
|
176
|
+
revokedAt: null,
|
|
177
|
+
revokedBy: null,
|
|
178
|
+
eventCount: Number(r.n),
|
|
179
|
+
user: r.user,
|
|
180
|
+
},
|
|
181
|
+
]
|
|
182
|
+
: [],
|
|
183
|
+
),
|
|
147
184
|
];
|
|
148
185
|
// Per account (by email), live before revoked, most recently used first,
|
|
149
186
|
// newest first among the never-used — the order the page shows without
|
|
@@ -175,6 +212,17 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
175
212
|
}
|
|
176
213
|
|
|
177
214
|
async ownerOf(principal: AuditPrincipalRef): Promise<string | null> {
|
|
215
|
+
if (principal.kind === 'page') {
|
|
216
|
+
// The person is in the id; the page exists for them once it has called something.
|
|
217
|
+
const page = parsePagePrincipalId(principal.id);
|
|
218
|
+
if (!page) return null;
|
|
219
|
+
const [row] = await this.db
|
|
220
|
+
.select({ id: agentEvents.id })
|
|
221
|
+
.from(agentEvents)
|
|
222
|
+
.where(and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path)))
|
|
223
|
+
.limit(1);
|
|
224
|
+
return row ? page.userId : null;
|
|
225
|
+
}
|
|
178
226
|
if (principal.kind === 'agent') {
|
|
179
227
|
const [row] = await this.db
|
|
180
228
|
.select({ userId: agentConnections.userId })
|
|
@@ -192,10 +240,7 @@ export class AgentAuditService implements IAgentAuditService, IAgentEventRecorde
|
|
|
192
240
|
page: { before?: string; limit: number },
|
|
193
241
|
): Promise<AgentEventPage> {
|
|
194
242
|
const limit = Math.max(1, Math.min(MAX_EVENT_PAGE, Math.floor(page.limit)));
|
|
195
|
-
const byPrincipal =
|
|
196
|
-
principal.kind === 'key'
|
|
197
|
-
? eq(agentEvents.keyId, principal.id)
|
|
198
|
-
: eq(agentEvents.connectionId, principal.id);
|
|
243
|
+
const byPrincipal = principalFilter(principal);
|
|
199
244
|
// Keyset on (at, id): a page boundary between two events at the same
|
|
200
245
|
// instant is still a boundary, and a row inserted while the reader pages
|
|
201
246
|
// never shifts what the next page returns.
|
|
@@ -340,6 +385,21 @@ function formatCursor(at: Date, id: string): string {
|
|
|
340
385
|
* compare it with anything else) and the instant a whole number of epoch
|
|
341
386
|
* milliseconds a `timestamp` can hold.
|
|
342
387
|
*/
|
|
388
|
+
/** The events of one principal. A page id that names no page matches nothing. */
|
|
389
|
+
function principalFilter(principal: AuditPrincipalRef): SQL {
|
|
390
|
+
if (principal.kind === 'key') return eq(agentEvents.keyId, principal.id);
|
|
391
|
+
if (principal.kind === 'agent') return eq(agentEvents.connectionId, principal.id);
|
|
392
|
+
const page = parsePagePrincipalId(principal.id);
|
|
393
|
+
if (!page) return isNull(agentEvents.id);
|
|
394
|
+
return and(eq(agentEvents.userId, page.userId), eq(agentEvents.pagePath, page.path))!;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** An aggregate's timestamp, which a driver may hand back as a Date or as the text Postgres sent. */
|
|
398
|
+
function toMs(value: Date | string | null): number {
|
|
399
|
+
if (value === null) return 0;
|
|
400
|
+
return value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
401
|
+
}
|
|
402
|
+
|
|
343
403
|
function parseCursor(raw: string): { at: Date; id: string } | null {
|
|
344
404
|
const dot = raw.indexOf('.');
|
|
345
405
|
if (dot < 0) return null;
|
|
@@ -24,19 +24,71 @@ declare global {
|
|
|
24
24
|
/** What was called: the platform's own tool, a manual's tool, or a skill read. */
|
|
25
25
|
export type AgentEventKind = 'capability' | 'tool' | 'skill';
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* How the call ended. `denied`: the caller's sign-in for the tool was
|
|
29
|
+
* missing, so nothing ran. `refused`: a page made a call that needs the
|
|
30
|
+
* viewer's approval (a writing tool, a tool the manual does not list, or a
|
|
31
|
+
* chain) and the person viewing it answered Deny, so nothing ran.
|
|
32
|
+
*/
|
|
33
|
+
export type AgentEventOutcome = 'ok' | 'error' | 'denied' | 'refused';
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Who a call is attributed to: the connection key it was authenticated with,
|
|
32
|
-
*
|
|
37
|
+
* the agent connection behind its OAuth grant, or the knowledge-base page
|
|
38
|
+
* whose script made it as the person viewing it. Exactly one.
|
|
39
|
+
*
|
|
40
|
+
* A page has no row of its own: it is a person and a path, and its `id` is
|
|
41
|
+
* both, encoded by {@link pagePrincipalId} so the Audit log's routes can name
|
|
42
|
+
* it like the other two.
|
|
43
|
+
*/
|
|
44
|
+
export type AuditPrincipalRef = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; id: string };
|
|
45
|
+
|
|
46
|
+
/** Who a call is attributed to, as the recorder takes it: a page by its path, the recorder knows the person. */
|
|
47
|
+
export type RecordedPrincipal = { kind: 'key'; id: string } | { kind: 'agent'; id: string } | { kind: 'page'; path: string };
|
|
48
|
+
|
|
49
|
+
/** The longest page path a page principal may carry. A path is a file in the knowledge base, never longer. */
|
|
50
|
+
export const MAX_PAGE_PATH_LENGTH = 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A page principal's id: the person and the page's path, encoded so it is one
|
|
54
|
+
* URL segment. Opaque to the client, which only ever echoes it back.
|
|
33
55
|
*/
|
|
34
|
-
export
|
|
56
|
+
export function pagePrincipalId(userId: string, path: string): string {
|
|
57
|
+
return Buffer.from(`${userId}\n${path}`, 'utf8').toString('base64url');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The person and the path a page principal's id names, or null when it names none. */
|
|
61
|
+
export function parsePagePrincipalId(id: string): { userId: string; path: string } | null {
|
|
62
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) return null;
|
|
63
|
+
const decoded = Buffer.from(id, 'base64url').toString('utf8');
|
|
64
|
+
const cut = decoded.indexOf('\n');
|
|
65
|
+
if (cut < 0) return null;
|
|
66
|
+
const userId = decoded.slice(0, cut);
|
|
67
|
+
const path = decoded.slice(cut + 1);
|
|
68
|
+
if (!isUuid(userId) || !isPagePath(path)) return null;
|
|
69
|
+
return { userId, path };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `path` can be a page principal: a non-empty knowledge-base path of
|
|
74
|
+
* printable characters, bounded. A page names itself through a request
|
|
75
|
+
* header, so this is the check that keeps a line break or a control character
|
|
76
|
+
* out of the log and its listing — the C1 controls (NEL among them) and the
|
|
77
|
+
* Unicode line and paragraph separators included.
|
|
78
|
+
*/
|
|
79
|
+
export function isPagePath(path: string): boolean {
|
|
80
|
+
return (
|
|
81
|
+
path.length > 0 &&
|
|
82
|
+
path.length <= MAX_PAGE_PATH_LENGTH &&
|
|
83
|
+
// eslint-disable-next-line no-control-regex -- the point is to refuse control characters
|
|
84
|
+
!/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(path)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
35
87
|
|
|
36
88
|
/** One recorded call, as the recorder takes it. */
|
|
37
89
|
export interface AgentEventInput {
|
|
38
90
|
userId: string;
|
|
39
|
-
principal:
|
|
91
|
+
principal: RecordedPrincipal;
|
|
40
92
|
kind: AgentEventKind;
|
|
41
93
|
/** The MCP server / `.tool` manual for a tool, the skill folder for a skill; null for a capability. */
|
|
42
94
|
manual: string | null;
|
|
@@ -54,18 +106,18 @@ export interface IAgentEventRecorder {
|
|
|
54
106
|
record(event: AgentEventInput): void;
|
|
55
107
|
}
|
|
56
108
|
|
|
57
|
-
/** One row of the Audit log: an agent connection
|
|
109
|
+
/** One row of the Audit log: an agent connection, a connection key or a page, with its owner. */
|
|
58
110
|
export interface AuditPrincipal {
|
|
59
|
-
kind: 'key' | 'agent';
|
|
111
|
+
kind: 'key' | 'agent' | 'page';
|
|
60
112
|
id: string;
|
|
61
|
-
/** The key's label,
|
|
113
|
+
/** The key's label, the agent's registered client name, or the page's knowledge-base path. */
|
|
62
114
|
label: string;
|
|
63
115
|
/**
|
|
64
116
|
* For a key: what it was minted as (`key` by hand, `github-link` for a
|
|
65
117
|
* Claude link). Absent for an agent.
|
|
66
118
|
*/
|
|
67
119
|
keyKind?: string;
|
|
68
|
-
/** When the key was created / the agent connected. Epoch ms. */
|
|
120
|
+
/** When the key was created / the agent connected / the page first called a tool. Epoch ms. */
|
|
69
121
|
createdAt: number;
|
|
70
122
|
lastUsedAt: number | null;
|
|
71
123
|
revokedAt: number | null;
|
|
@@ -149,7 +201,7 @@ export interface IAgentConnectionStatus {
|
|
|
149
201
|
|
|
150
202
|
export class AuditPrincipalNotFoundError extends Error {
|
|
151
203
|
constructor() {
|
|
152
|
-
super('No such agent
|
|
204
|
+
super('No such agent');
|
|
153
205
|
this.name = 'AuditPrincipalNotFoundError';
|
|
154
206
|
}
|
|
155
207
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AuditPrincipalNotFoundError,
|
|
8
8
|
InvalidCursorError,
|
|
9
9
|
isUuid,
|
|
10
|
+
parsePagePrincipalId,
|
|
10
11
|
type AuditPrincipalRef,
|
|
11
12
|
type IAgentAuditService,
|
|
12
13
|
} from './audit.contract.js';
|
|
@@ -20,7 +21,7 @@ const DEFAULT_EVENT_PAGE = 50;
|
|
|
20
21
|
/**
|
|
21
22
|
* The Audit log's routes, JWT-only (mounted behind the session middleware):
|
|
22
23
|
*
|
|
23
|
-
* GET /api/audit/principals?scope=me|all — agents
|
|
24
|
+
* GET /api/audit/principals?scope=me|all — agents, keys and pages, with owners and event counts
|
|
24
25
|
* GET /api/audit/principals/:kind/:id/events — one principal's events, newest first, cursor-paged
|
|
25
26
|
* DELETE /api/audit/agents/:id — revoke an agent (owner or admin)
|
|
26
27
|
* DELETE /api/audit/keys/:id — revoke a key (owner or admin)
|
|
@@ -44,14 +45,20 @@ export function createAuditRoutes(
|
|
|
44
45
|
const principalOf = (req: express.Request): AuditPrincipalRef | null => {
|
|
45
46
|
const kind = String(req.params.kind);
|
|
46
47
|
const id = String(req.params.id);
|
|
48
|
+
if (kind === 'page') return parsePagePrincipalId(id) ? { kind, id } : null;
|
|
47
49
|
if ((kind !== 'key' && kind !== 'agent') || !isUuid(id)) return null;
|
|
48
50
|
return { kind, id };
|
|
49
51
|
};
|
|
50
52
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Whether the caller may read `principal`'s events: its owner, or an admin.
|
|
55
|
+
* False both when there is no such principal and when it is someone else's,
|
|
56
|
+
* so the refusal does not tell a member which of another person's pages,
|
|
57
|
+
* agents or keys exist.
|
|
58
|
+
*/
|
|
59
|
+
const mayRead = async (req: express.Request, principal: AuditPrincipalRef): Promise<boolean> => {
|
|
53
60
|
const owner = await audit.ownerOf(principal);
|
|
54
|
-
if (owner === null) return
|
|
61
|
+
if (owner === null) return false;
|
|
55
62
|
if (owner === req.userId) return true;
|
|
56
63
|
return isAdmin(req);
|
|
57
64
|
};
|
|
@@ -74,17 +81,12 @@ export function createAuditRoutes(
|
|
|
74
81
|
router.get('/audit/principals/:kind/:id/events', async (req, res) => {
|
|
75
82
|
const principal = principalOf(req);
|
|
76
83
|
if (!principal) {
|
|
77
|
-
res.status(404).json({ error: 'No such agent or
|
|
84
|
+
res.status(404).json({ error: 'No such agent, key or page' });
|
|
78
85
|
return;
|
|
79
86
|
}
|
|
80
87
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
res.status(404).json({ error: 'No such agent or key' });
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (!allowed) {
|
|
87
|
-
res.status(403).json({ error: 'Not yours' });
|
|
88
|
+
if (!(await mayRead(req, principal))) {
|
|
89
|
+
res.status(404).json({ error: 'No such agent, key or page' });
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
90
92
|
const rawLimit = Number(req.query.limit);
|
|
@@ -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
|
|
|
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
|
8
8
|
import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
9
9
|
import { DiffService } from '../diff.service.js';
|
|
10
10
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Atomic-seed regression suite.
|
|
@@ -53,7 +54,7 @@ async function setup(): Promise<Fixture> {
|
|
|
53
54
|
const workspaceDir = path.join(workspacesRoot, workspaceId);
|
|
54
55
|
const repoDir = path.join(workspaceDir, 'knowledge-base');
|
|
55
56
|
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
|
|
56
|
-
const workspaceService =
|
|
57
|
+
const workspaceService = newWorkspaceService(
|
|
57
58
|
workspacesRoot,
|
|
58
59
|
'https://github.com/Bevel-Software/knowledge-base.git',
|
|
59
60
|
testKbContext(),
|
|
@@ -8,6 +8,7 @@ import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
|
8
8
|
import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
9
9
|
import { DiffService } from '../diff.service.js';
|
|
10
10
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
11
12
|
|
|
12
13
|
async function mkTmpRoot(prefix: string): Promise<string> {
|
|
13
14
|
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
@@ -39,7 +40,7 @@ async function setup(): Promise<Fixture> {
|
|
|
39
40
|
const repoDir = path.join(workspaceDir, 'knowledge-base');
|
|
40
41
|
// Inner `.git` dir is the marker `resolveWorkspaceDir` looks for.
|
|
41
42
|
await fs.mkdir(path.join(repoDir, '.git'), { recursive: true });
|
|
42
|
-
const workspaceService =
|
|
43
|
+
const workspaceService = newWorkspaceService(
|
|
43
44
|
workspacesRoot,
|
|
44
45
|
'https://github.com/Bevel-Software/knowledge-base.git',
|
|
45
46
|
testKbContext(),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import { NodeFs } from '../../kb-fs/node-fs.js';
|
|
8
|
+
import { WorkspaceMutex } from '../../kb-fs/mutex.js';
|
|
9
|
+
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
10
|
+
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
11
|
+
import { DiffService } from '../diff.service.js';
|
|
12
|
+
import { newWorkspaceService } from '../../../__tests__/workspace-service.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The review session names the branch the workspace is on, read from HEAD
|
|
16
|
+
* without spawning git. In a worktree `.git` is a FILE naming the worktree's
|
|
17
|
+
* folder in the repository, where its HEAD lives.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const execFileAsync = promisify(execFile);
|
|
21
|
+
|
|
22
|
+
async function git(cwd: string, args: string[]): Promise<void> {
|
|
23
|
+
await execFileAsync('git', args, {
|
|
24
|
+
cwd,
|
|
25
|
+
env: {
|
|
26
|
+
...process.env,
|
|
27
|
+
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
|
|
28
|
+
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let root: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-diff-worktree-'));
|
|
37
|
+
});
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('DiffService on a worktree', () => {
|
|
43
|
+
it("names the worktree's own branch in the review session", async () => {
|
|
44
|
+
const upstream = path.join(root, 'upstream.git');
|
|
45
|
+
await git(root, ['init', '--bare', '-b', 'main', upstream]);
|
|
46
|
+
const seed = path.join(root, '.seed');
|
|
47
|
+
await fs.mkdir(seed);
|
|
48
|
+
await git(seed, ['init', '-b', 'alice/draft']);
|
|
49
|
+
await fs.writeFile(path.join(seed, 'page.md'), 'before\n');
|
|
50
|
+
await git(seed, ['add', '-A']);
|
|
51
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
52
|
+
await git(seed, ['push', '-q', upstream, 'alice/draft']);
|
|
53
|
+
|
|
54
|
+
const workspacesRoot = path.join(root, 'workspaces');
|
|
55
|
+
const workspaces = newWorkspaceService(workspacesRoot, upstream, testKbContext(), new NodeFs());
|
|
56
|
+
const diff = new DiffService(
|
|
57
|
+
workspaces,
|
|
58
|
+
new WorkspaceMutex(),
|
|
59
|
+
workspacesRoot,
|
|
60
|
+
path.join(root, 'backups'),
|
|
61
|
+
'knowledge-base',
|
|
62
|
+
new NodeFs(),
|
|
63
|
+
);
|
|
64
|
+
workspaces.setDiffService(diff);
|
|
65
|
+
const workspaceId = workspaceIdForBranch('alice/draft');
|
|
66
|
+
const info = await workspaces.getOrCreateForBranch('alice/draft');
|
|
67
|
+
expect((await fs.stat(path.join(info.absolutePath, 'knowledge-base', '.git'))).isFile()).toBe(true);
|
|
68
|
+
|
|
69
|
+
// The first look seeds the ledger; a change made after it is pending.
|
|
70
|
+
expect(await diff.currentSession(workspaceId)).toBeNull();
|
|
71
|
+
await fs.writeFile(path.join(info.absolutePath, 'knowledge-base', 'page.md'), 'after\n');
|
|
72
|
+
|
|
73
|
+
const session = await diff.currentSession(workspaceId);
|
|
74
|
+
expect(session?.branchName).toBe('alice/draft');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -4,6 +4,7 @@ import type { FileDiffPayload, PendingChange, ReviewSession, ChangeKind } from '
|
|
|
4
4
|
import type { IDiffService } from './diff.interface.js';
|
|
5
5
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
6
6
|
import type { WorkspaceMutex } from '../kb-fs/mutex.js';
|
|
7
|
+
import { gitDirOf } from '../kb-fs/index.js';
|
|
7
8
|
import { isAbsence, type ITreeWalker, type TreeWalkOptions } from '../../shared/fs.contract.js';
|
|
8
9
|
import { isDiffable } from './diff.config.js';
|
|
9
10
|
import { assertWithinDirectory } from '../../shared/path-containment.js';
|
|
@@ -385,10 +386,11 @@ export class DiffService implements IDiffService {
|
|
|
385
386
|
private async tryReadCurrentBranch(workspaceId: string): Promise<string> {
|
|
386
387
|
const workspaceDir = await this.workspaceService.getWorkspacePath(workspaceId);
|
|
387
388
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
);
|
|
389
|
+
// A worktree's `.git` is a FILE naming its folder in the repository,
|
|
390
|
+
// where its HEAD lives; a full clone's is the folder itself.
|
|
391
|
+
const gitDir = await gitDirOf(path.join(workspaceDir, this.kbDirName));
|
|
392
|
+
if (gitDir === null) return '';
|
|
393
|
+
const head = await fs.readFile(path.join(gitDir, 'HEAD'), 'utf-8');
|
|
392
394
|
const m = head.match(/^ref:\s+refs\/heads\/(.+?)\s*$/);
|
|
393
395
|
return m ? m[1] : '';
|
|
394
396
|
} catch {
|