@bevel-software/platform-core-backend 0.34.0 → 0.35.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 +146 -300
- package/agent-guide/conventions.md +23 -25
- package/agent-guide/directory-structure.md +72 -120
- package/agent-guide/drafts-and-change-requests.md +92 -0
- package/agent-guide/for-git-users.md +47 -0
- package/agent-guide/glossary.md +36 -0
- package/agent-guide/how-agents-find-things.md +49 -0
- package/agent-guide/html-views.md +16 -11
- package/agent-guide/new-knowledge-base.md +9 -8
- package/agent-guide/skills.md +67 -42
- package/agent-guide/start-here.md +65 -0
- package/agent-guide/tool-manuals.md +132 -154
- package/agent-guide/tools.md +67 -0
- package/agent-guide/where-a-new-file-goes.md +45 -28
- package/dist/core/create-core-server.js +1 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +6 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +7 -4
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +22 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +3 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +75 -0
- 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 +23 -4
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/index.d.ts +1 -1
- package/dist/modules/access/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
- package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.js +103 -13
- package/dist/modules/agent-guide/agent-guide.js.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
- package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
- package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
- package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
- package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
- package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
- package/dist/modules/agent-guide/guide-budget.js +67 -0
- package/dist/modules/agent-guide/guide-budget.js.map +1 -0
- package/dist/modules/agent-guide/index.d.ts +2 -1
- package/dist/modules/agent-guide/index.d.ts.map +1 -1
- package/dist/modules/agent-guide/index.js +2 -1
- package/dist/modules/agent-guide/index.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +4 -0
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +2 -2
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
- package/dist/modules/connection-probe/connection-probe.service.js +1 -38
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -35
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/index.d.ts +1 -1
- package/dist/modules/secrets-vault/index.d.ts.map +1 -1
- package/dist/modules/secrets-vault/index.js +1 -1
- package/dist/modules/secrets-vault/index.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
- package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
- package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/oidc-check.d.ts.map +1 -1
- package/dist/modules/settings/oidc-check.js +5 -17
- package/dist/modules/settings/oidc-check.js.map +1 -1
- package/dist/modules/skills/skills.tools.js +1 -1
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/tool-def.js +1 -1
- package/dist/modules/tool-helpers/tool-def.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
- package/dist/modules/workspace/agent-access.gate.js +1 -1
- package/dist/modules/workspace/agent-access.gate.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +3 -2
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +71 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/oauth-token-error.d.ts +23 -0
- package/dist/shared/oauth-token-error.d.ts.map +1 -0
- package/dist/shared/oauth-token-error.js +47 -0
- package/dist/shared/oauth-token-error.js.map +1 -0
- package/dist/shared/read-capped.d.ts +19 -0
- package/dist/shared/read-capped.d.ts.map +1 -0
- package/dist/shared/read-capped.js +45 -0
- package/dist/shared/read-capped.js.map +1 -0
- package/package.json +4 -3
- package/src/__tests__/hexis-catalog.ts +144 -0
- package/src/__tests__/real-disk.ts +21 -0
- package/src/__tests__/tokens.ts +19 -0
- package/src/__tests__/tool-manuals.ts +32 -0
- package/src/__tests__/tool-wiring.ts +25 -0
- package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
- package/src/core/create-core-server.ts +1 -1
- package/src/core/create-core-services.ts +13 -3
- package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
- package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
- package/src/modules/access/access-control.interface.ts +26 -0
- package/src/modules/access/access-control.service.ts +92 -0
- package/src/modules/access/access.routes.ts +23 -4
- package/src/modules/access/index.ts +1 -1
- package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
- package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
- package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
- package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
- package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
- package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
- package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
- package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
- package/src/modules/agent-guide/agent-guide.ts +120 -13
- package/src/modules/agent-guide/guide-budget.ts +73 -0
- package/src/modules/agent-guide/index.ts +12 -0
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
- package/src/modules/agent-instructions/compose.ts +4 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
- package/src/modules/code-mode/code-mode.tool.ts +2 -2
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
- package/src/modules/connection-probe/connection-probe.service.ts +1 -34
- package/src/modules/mcp/mcp.service.ts +5 -35
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
- package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
- package/src/modules/secrets-vault/index.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
- package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
- package/src/modules/settings/oidc-check.ts +5 -18
- package/src/modules/skills/skills.tools.ts +1 -1
- package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
- package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
- package/src/modules/tool-helpers/tool-def.ts +1 -1
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
- package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
- package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
- package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
- package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
- package/src/modules/workspace/agent-access.gate.ts +1 -1
- package/src/modules/workspace/workspace.tools.ts +74 -21
- package/src/shared/__tests__/read-capped.test.ts +68 -0
- package/src/shared/oauth-token-error.ts +46 -0
- package/src/shared/read-capped.ts +40 -0
- package/agent-guide/finding-things.md +0 -7
- package/agent-guide/introduction.md +0 -27
|
@@ -3,15 +3,14 @@ import type { AddressInfo } from 'node:net';
|
|
|
3
3
|
import express from 'express';
|
|
4
4
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
5
5
|
|
|
6
|
-
import type { IAccessControl, ProspectiveHolders } from '../access-control.interface.js';
|
|
7
|
-
import type { WorkspaceService } from '../../workspace/
|
|
8
|
-
import type { AuthService } from '../../auth/
|
|
9
|
-
import type { WorkflowService } from '../../workflow/
|
|
10
|
-
import type {
|
|
11
|
-
import type { Database } from '../../database/connection.js';
|
|
6
|
+
import type { HolderChanges, IAccessControl, ProspectiveHolders } from '../access-control.interface.js';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
8
|
+
import type { AuthService } from '../../auth/index.js';
|
|
9
|
+
import type { WorkflowEventBus, WorkflowService } from '../../workflow/index.js';
|
|
10
|
+
import type { Database } from '../../database/index.js';
|
|
12
11
|
import { createAccessRoutes } from '../access.routes.js';
|
|
13
12
|
import { testKbContext } from '../../../__tests__/kb-context.js';
|
|
14
|
-
import {
|
|
13
|
+
import { realDisk } from '../../../__tests__/real-disk.js';
|
|
15
14
|
|
|
16
15
|
/**
|
|
17
16
|
* HTTP contract for `GET /access/prospective` — what the move confirmation
|
|
@@ -41,17 +40,28 @@ const HOLDERS: ProspectiveHolders = {
|
|
|
41
40
|
},
|
|
42
41
|
};
|
|
43
42
|
|
|
43
|
+
/** What the resolver judges changed: Engineering loses editing, Ali gains opening. */
|
|
44
|
+
const CHANGES: HolderChanges = {
|
|
45
|
+
lose: [{ kind: 'group', name: 'Engineering', verb: 'write' }],
|
|
46
|
+
gain: [{ kind: 'person', name: 'ali@bevel.software', email: 'ali@bevel.software', verb: 'read' }],
|
|
47
|
+
};
|
|
48
|
+
|
|
44
49
|
interface Harness {
|
|
45
50
|
server: Server;
|
|
46
51
|
baseUrl: string;
|
|
47
52
|
canRead: ReturnType<typeof vi.fn>;
|
|
48
53
|
prospectiveHolders: ReturnType<typeof vi.fn>;
|
|
54
|
+
prospectiveHolderChanges: ReturnType<typeof vi.fn>;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
/** `canRead` answers `opts.canRead` for every path, or per path through `opts.readable`. */
|
|
58
|
+
async function makeHarness(opts: { canRead?: boolean; readable?: (path: string) => boolean } = {}): Promise<Harness> {
|
|
59
|
+
const canRead = vi.fn(async (_ws: string, _email: string, path: string) =>
|
|
60
|
+
opts.readable ? opts.readable(path) : (opts.canRead ?? true),
|
|
61
|
+
);
|
|
53
62
|
const prospectiveHolders = vi.fn(async () => HOLDERS);
|
|
54
|
-
const
|
|
63
|
+
const prospectiveHolderChanges = vi.fn(async () => CHANGES);
|
|
64
|
+
const accessControl = { canRead, prospectiveHolders, prospectiveHolderChanges } as unknown as IAccessControl;
|
|
55
65
|
|
|
56
66
|
const app = express();
|
|
57
67
|
app.use(express.json());
|
|
@@ -70,7 +80,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
|
|
|
70
80
|
{ emit: vi.fn() } as unknown as WorkflowEventBus,
|
|
71
81
|
{} as unknown as Database,
|
|
72
82
|
testKbContext({ kbDirName: KB }),
|
|
73
|
-
|
|
83
|
+
realDisk(),
|
|
74
84
|
),
|
|
75
85
|
);
|
|
76
86
|
|
|
@@ -78,7 +88,7 @@ async function makeHarness(opts: { canRead?: boolean } = {}): Promise<Harness> {
|
|
|
78
88
|
const s = app.listen(0, () => resolve(s));
|
|
79
89
|
});
|
|
80
90
|
const addr = server.address() as AddressInfo;
|
|
81
|
-
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, prospectiveHolders };
|
|
91
|
+
return { server, baseUrl: `http://127.0.0.1:${addr.port}`, canRead, prospectiveHolders, prospectiveHolderChanges };
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
function close(s: Server): Promise<void> {
|
|
@@ -110,6 +120,13 @@ describe('GET /access/prospective', () => {
|
|
|
110
120
|
'Knowledge/Legal/contract.md',
|
|
111
121
|
'Knowledge/Sales/contract.md',
|
|
112
122
|
);
|
|
123
|
+
// The resolver's own changes ride along: the one answer the move dialog
|
|
124
|
+
// renders, and the same the agent's `move_file` gives.
|
|
125
|
+
expect(h.prospectiveHolderChanges).toHaveBeenCalledWith(
|
|
126
|
+
WS,
|
|
127
|
+
'Knowledge/Legal/contract.md',
|
|
128
|
+
'Knowledge/Sales/contract.md',
|
|
129
|
+
);
|
|
113
130
|
expect(await res.json()).toEqual({
|
|
114
131
|
before: {
|
|
115
132
|
read: [{ kind: 'group', name: 'Engineering' }],
|
|
@@ -122,9 +139,32 @@ describe('GET /access/prospective', () => {
|
|
|
122
139
|
],
|
|
123
140
|
write: [],
|
|
124
141
|
},
|
|
142
|
+
changes: CHANGES,
|
|
125
143
|
});
|
|
126
144
|
});
|
|
127
145
|
|
|
146
|
+
it('refuses a caller who cannot read the destination folder, resolving nothing: the lists and changes are that folder\'s rules read back', async () => {
|
|
147
|
+
h = await makeHarness({ readable: (path) => path !== 'Knowledge/Sales' });
|
|
148
|
+
|
|
149
|
+
const res = await get(
|
|
150
|
+
`from=${encodeURIComponent('Knowledge/Legal/contract.md')}&toDir=${encodeURIComponent('Knowledge/Sales')}`,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(res.status).toBe(403);
|
|
154
|
+
expect(await res.json()).toEqual({ error: 'You do not have access to this folder.' });
|
|
155
|
+
expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, 'Knowledge/Sales');
|
|
156
|
+
expect(h.prospectiveHolders).not.toHaveBeenCalled();
|
|
157
|
+
expect(h.prospectiveHolderChanges).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('asks the read gate about the repo root when the destination is the root', async () => {
|
|
161
|
+
h = await makeHarness();
|
|
162
|
+
|
|
163
|
+
await get(`from=${encodeURIComponent('Knowledge/Legal/contract.md')}&toDir=`);
|
|
164
|
+
|
|
165
|
+
expect(h.canRead).toHaveBeenCalledWith(WS, USER.email, '');
|
|
166
|
+
});
|
|
167
|
+
|
|
128
168
|
it('strips the workspace-relative kb prefix from both ends', async () => {
|
|
129
169
|
h = await makeHarness();
|
|
130
170
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import { realDisk } from '../../../__tests__/real-disk.js';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/index.js';
|
|
8
|
+
import { AccessControlService } from '../access-control.service.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `prospectiveHolderChanges`: who loses and who gains opening or editing a
|
|
12
|
+
* file or folder in a move, over a real on-disk tree. The holder lists name
|
|
13
|
+
* grants, so these tests are about the cases a list comparison gets wrong: a
|
|
14
|
+
* group still covered by `everyone` where it lands, and someone a rule there
|
|
15
|
+
* denies by name, even when their only access before was `everyone`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const KB_DIR = 'knowledge-base';
|
|
19
|
+
const rules = (body: string) => `---\n${body}---\n`;
|
|
20
|
+
|
|
21
|
+
describe('AccessControlService.prospectiveHolderChanges', () => {
|
|
22
|
+
let root: string;
|
|
23
|
+
let repo: string;
|
|
24
|
+
const workspaceId = 'ws-holder-changes';
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-holder-changes-'));
|
|
28
|
+
repo = path.join(root, workspaceId, KB_DIR);
|
|
29
|
+
});
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
if (root) await fs.rm(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function write(files: Record<string, string>): Promise<void> {
|
|
35
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
36
|
+
const abs = path.join(repo, rel);
|
|
37
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
38
|
+
await fs.writeFile(abs, contents);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const service = () =>
|
|
43
|
+
new AccessControlService(
|
|
44
|
+
{
|
|
45
|
+
getWorkspacePath: async () => path.join(root, workspaceId),
|
|
46
|
+
ensureRemotesFetched: async () => undefined,
|
|
47
|
+
} as unknown as WorkspaceService,
|
|
48
|
+
KB_DIR,
|
|
49
|
+
realDisk(),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/** Everyone reads; Support and Operations each edit their own folder. */
|
|
53
|
+
const BASE = {
|
|
54
|
+
'roles.yaml': 'roles:\n Admin:\n - admin@x.io\n',
|
|
55
|
+
'groups.yaml': 'groups:\n Support:\n - bea@x.io\n Operations:\n - jonas@x.io\n',
|
|
56
|
+
'access.md': rules('read:\n - everyone\n'),
|
|
57
|
+
'Support/access.md': rules('write:\n - Support\n'),
|
|
58
|
+
'Support/Runbooks/Warranty claim.md': '# Warranty claim\n',
|
|
59
|
+
'Operations/access.md': rules('write:\n - Operations\n'),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
it('a folder handed to another team: the old team loses editing, the new one gains it, and nobody loses opening it', async () => {
|
|
63
|
+
await write(BASE);
|
|
64
|
+
const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Operations/Runbooks');
|
|
65
|
+
expect(changes).toEqual({
|
|
66
|
+
lose: [{ kind: 'group', name: 'Support', verb: 'write' }],
|
|
67
|
+
gain: [{ kind: 'group', name: 'Operations', verb: 'write' }],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('a person the destination denies by name loses access, though everyone may still read there', async () => {
|
|
72
|
+
await write({
|
|
73
|
+
...BASE,
|
|
74
|
+
'Team/access.md': rules('write:\n - Ana <ana@x.io>\n'),
|
|
75
|
+
'Team/plan.md': '# Plan\n',
|
|
76
|
+
'Closed/access.md': rules('read:\n - everyone\n - deny Ana <ana@x.io>\n'),
|
|
77
|
+
});
|
|
78
|
+
const changes = await service().prospectiveHolderChanges(workspaceId, 'Team/plan.md', 'Closed/plan.md');
|
|
79
|
+
expect(changes.lose).toEqual(
|
|
80
|
+
expect.arrayContaining([
|
|
81
|
+
{ kind: 'person', name: 'ana@x.io', email: 'ana@x.io', verb: 'read' },
|
|
82
|
+
{ kind: 'person', name: 'ana@x.io', email: 'ana@x.io', verb: 'write' },
|
|
83
|
+
]),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('a person whose only access was everyone, and whom the destination denies by name, loses it', async () => {
|
|
88
|
+
await write({
|
|
89
|
+
...BASE,
|
|
90
|
+
'Closed/access.md': rules('read:\n - everyone\n - deny Leo <leo@x.io>\n'),
|
|
91
|
+
});
|
|
92
|
+
const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
|
|
93
|
+
expect(changes.lose).toContainEqual({ kind: 'person', name: 'leo@x.io', email: 'leo@x.io', verb: 'read' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('a group whose only read was everyone, and which the destination denies, loses it', async () => {
|
|
97
|
+
await write({
|
|
98
|
+
...BASE,
|
|
99
|
+
'Closed/access.md': rules('read:\n - everyone\n - deny Operations\n'),
|
|
100
|
+
});
|
|
101
|
+
const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
|
|
102
|
+
expect(changes.lose).toContainEqual({ kind: 'group', name: 'Operations', verb: 'read' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('a group the destination denies loses opening it, though everyone may still read there', async () => {
|
|
106
|
+
await write({
|
|
107
|
+
...BASE,
|
|
108
|
+
'Closed/access.md': rules('read:\n - everyone\n - deny Support\n'),
|
|
109
|
+
});
|
|
110
|
+
const changes = await service().prospectiveHolderChanges(workspaceId, 'Support/Runbooks', 'Closed/Runbooks');
|
|
111
|
+
expect(changes.lose).toEqual(
|
|
112
|
+
expect.arrayContaining([
|
|
113
|
+
{ kind: 'group', name: 'Support', verb: 'read' },
|
|
114
|
+
{ kind: 'group', name: 'Support', verb: 'write' },
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('a rename that carries its own rules changes nobody', async () => {
|
|
120
|
+
await write({
|
|
121
|
+
...BASE,
|
|
122
|
+
'Sales/access.md': rules('read:\n - Support\nwrite:\n - Support\n'),
|
|
123
|
+
'Sales/deal.md': '# Deal\n',
|
|
124
|
+
});
|
|
125
|
+
expect(await service().prospectiveHolderChanges(workspaceId, 'Sales', 'Revenue')).toEqual({ lose: [], gain: [] });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -92,6 +92,17 @@ export type PathHolders = { read: HolderList; write: HolderList };
|
|
|
92
92
|
*/
|
|
93
93
|
export type ProspectiveHolders = { before: PathHolders; after: PathHolders };
|
|
94
94
|
|
|
95
|
+
/** One principal that loses or gains one verb in a move. A person is named by their address when they have no display name. */
|
|
96
|
+
export type HolderChange = {
|
|
97
|
+
kind: 'group' | 'role' | 'plugin' | 'person';
|
|
98
|
+
name: string;
|
|
99
|
+
email?: string;
|
|
100
|
+
verb: 'read' | 'write';
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Who loses and who gains opening or editing a file or folder in a move — see {@link IAccessControl.prospectiveHolderChanges}. */
|
|
104
|
+
export type HolderChanges = { lose: HolderChange[]; gain: HolderChange[] };
|
|
105
|
+
|
|
95
106
|
/** One caller's own verdict per access verb on one path. */
|
|
96
107
|
export type PathVerbs = { read: boolean; write: boolean; download: boolean; owner: boolean };
|
|
97
108
|
|
|
@@ -420,6 +431,21 @@ export interface IAccessControl {
|
|
|
420
431
|
toPath: string,
|
|
421
432
|
): Promise<ProspectiveHolders>;
|
|
422
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Who loses and who gains `read` or `write` on one file or folder if a move
|
|
436
|
+
* puts it at `toPath`, over the same relocation as
|
|
437
|
+
* {@link IAccessControl.prospectiveHolders}. A holder on one side loses (or
|
|
438
|
+
* gains) the verb only when it does not hold it on the other side: the
|
|
439
|
+
* lists name grants, so a group no rule names at the destination may still
|
|
440
|
+
* read there through `everyone`, and one a rule denies there may not.
|
|
441
|
+
* Writes nothing and moves nothing.
|
|
442
|
+
*/
|
|
443
|
+
prospectiveHolderChanges(
|
|
444
|
+
workspaceId: string,
|
|
445
|
+
fromPath: string,
|
|
446
|
+
toPath: string,
|
|
447
|
+
): Promise<HolderChanges>;
|
|
448
|
+
|
|
423
449
|
/**
|
|
424
450
|
* The CALLER'S OWN `{ read, write, download, owner }` at `toPath` as they
|
|
425
451
|
* will be once `fromPath` has been moved (`sourceRemains: false`, the
|
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
GrantSources,
|
|
23
23
|
PathHolders,
|
|
24
24
|
PathVerbs,
|
|
25
|
+
HolderChange,
|
|
26
|
+
HolderChanges,
|
|
25
27
|
ProspectiveHolders,
|
|
26
28
|
ResolvedPrincipal,
|
|
27
29
|
} from './access-control.interface.js';
|
|
@@ -1354,6 +1356,70 @@ function eligibleHoldersResolved(
|
|
|
1354
1356
|
return { principals, roles, users };
|
|
1355
1357
|
}
|
|
1356
1358
|
|
|
1359
|
+
/** One side of a move: the access model, the path, and the rules the file carries there. */
|
|
1360
|
+
type MoveSide = { model: AccessModel; path: string; own: OwnEntries | null };
|
|
1361
|
+
|
|
1362
|
+
/** The key set behind one entry of an eligible-holders list, read back from its name and kind. */
|
|
1363
|
+
function keysOfHolder(model: AccessModel, holder: ResolvedPrincipal): ReadonlySet<string> {
|
|
1364
|
+
for (const [canonical, record] of model.roles.byCanonical) {
|
|
1365
|
+
if (record.kind === holder.kind && record.displayName === holder.name) return principalKeysOfToken(model, canonical);
|
|
1366
|
+
}
|
|
1367
|
+
return principalKeysOfToken(model, canonicalRoleName(holder.name));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Who loses and who gains `verb` between the two sides of a move. The
|
|
1372
|
+
* candidates are every principal and person a rule on either side names for
|
|
1373
|
+
* the verb, as a grant or as a denial, and every holder either side lists;
|
|
1374
|
+
* each is put to both sides' resolvers as the gates would put it. The holder
|
|
1375
|
+
* lists alone are not enough: they name grants, so they miss a group still
|
|
1376
|
+
* covered by `everyone`, and they cannot show someone whose only access was
|
|
1377
|
+
* `everyone` and whom a rule on the far side denies by name.
|
|
1378
|
+
*/
|
|
1379
|
+
function holderChangesFor(verb: 'read' | 'write', here: MoveSide, there: MoveSide): HolderChanges {
|
|
1380
|
+
const collectives = new Map<string, { holder: ResolvedPrincipal; keys: ReadonlySet<string> }>();
|
|
1381
|
+
const people = new Set<string>();
|
|
1382
|
+
const addCollective = (holder: ResolvedPrincipal, keys: ReadonlySet<string>) => {
|
|
1383
|
+
const id = `${holder.kind}\0${holder.name.toLowerCase()}`;
|
|
1384
|
+
if (!collectives.has(id)) collectives.set(id, { holder, keys });
|
|
1385
|
+
};
|
|
1386
|
+
for (const side of [here, there]) {
|
|
1387
|
+
const held = eligibleHoldersResolved(side.model, verb, side.path, side.own);
|
|
1388
|
+
for (const holder of held.principals) addCollective(holder, keysOfHolder(side.model, holder));
|
|
1389
|
+
for (const user of held.users) people.add(user.email);
|
|
1390
|
+
const { byRole, byEmail } = resolveAtPath(side.model, verb, side.path, side.own);
|
|
1391
|
+
for (const token of byRole.keys()) {
|
|
1392
|
+
const record = side.model.roles.byCanonical.get(token);
|
|
1393
|
+
addCollective(
|
|
1394
|
+
{ name: record ? record.displayName : token, kind: record?.kind ?? 'role' },
|
|
1395
|
+
principalKeysOfToken(side.model, token),
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
for (const email of byEmail.keys()) people.add(email);
|
|
1399
|
+
}
|
|
1400
|
+
const lose: HolderChange[] = [];
|
|
1401
|
+
const gain: HolderChange[] = [];
|
|
1402
|
+
const record = (had: boolean, has: boolean, change: HolderChange) => {
|
|
1403
|
+
if (had && !has) lose.push(change);
|
|
1404
|
+
if (!had && has) gain.push(change);
|
|
1405
|
+
};
|
|
1406
|
+
for (const { holder, keys } of collectives.values()) {
|
|
1407
|
+
record(
|
|
1408
|
+
hasPermissionForKeys(here.model, verb, keys, here.path, here.own),
|
|
1409
|
+
hasPermissionForKeys(there.model, verb, keys, there.path, there.own),
|
|
1410
|
+
{ kind: holder.kind, name: holder.name, verb },
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
for (const email of [...people].sort()) {
|
|
1414
|
+
record(
|
|
1415
|
+
hasPermissionResolved(here.model, verb, email, here.path, here.own),
|
|
1416
|
+
hasPermissionResolved(there.model, verb, email, there.path, there.own),
|
|
1417
|
+
{ kind: 'person', name: email, email, verb },
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
return { lose, gain };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1357
1423
|
/**
|
|
1358
1424
|
* Every individual the model names: `roles.yaml` members, emails named inline
|
|
1359
1425
|
* at this path's scope, and the deployment owner (bound by every rule that
|
|
@@ -1928,6 +1994,32 @@ export class AccessControlService implements IAccessControl {
|
|
|
1928
1994
|
};
|
|
1929
1995
|
}
|
|
1930
1996
|
|
|
1997
|
+
/** Who loses and who gains `read` or `write` in a move (see the interface). */
|
|
1998
|
+
async prospectiveHolderChanges(
|
|
1999
|
+
workspaceId: string,
|
|
2000
|
+
fromPath: string,
|
|
2001
|
+
toPath: string,
|
|
2002
|
+
): Promise<HolderChanges> {
|
|
2003
|
+
const model = await this.loadModel(workspaceId);
|
|
2004
|
+
const repoDir = await this.repoDir(workspaceId);
|
|
2005
|
+
const { isFolder, ownHere, ownThere, landing } =
|
|
2006
|
+
await this.relocationSubject(repoDir, fromPath, toPath);
|
|
2007
|
+
const here: MoveSide = { model, path: fromPath, own: ownHere };
|
|
2008
|
+
const there: MoveSide = {
|
|
2009
|
+
model: withRelocatedAccessFiles(model, fromPath, toPath, { sourceRemains: false, sourceIsFolder: isFolder, landing }),
|
|
2010
|
+
path: toPath,
|
|
2011
|
+
own: ownThere,
|
|
2012
|
+
};
|
|
2013
|
+
const lose: HolderChange[] = [];
|
|
2014
|
+
const gain: HolderChange[] = [];
|
|
2015
|
+
for (const verb of ['read', 'write'] as const) {
|
|
2016
|
+
const changes = holderChangesFor(verb, here, there);
|
|
2017
|
+
lose.push(...changes.lose);
|
|
2018
|
+
gain.push(...changes.gain);
|
|
2019
|
+
}
|
|
2020
|
+
return { lose, gain };
|
|
2021
|
+
}
|
|
2022
|
+
|
|
1931
2023
|
/**
|
|
1932
2024
|
* The caller's own verbs at `toPath` as they will be once `fromPath` has
|
|
1933
2025
|
* been moved or copied there (see the interface).
|
|
@@ -414,9 +414,20 @@ export function createAccessRoutes(
|
|
|
414
414
|
* nothing is moved.
|
|
415
415
|
*
|
|
416
416
|
* Gated like the sibling `overrides` route rather than the permissive
|
|
417
|
-
* `GET /access`: the caller must resolve read on the thing being moved
|
|
418
|
-
* lists name people, and someone who cannot
|
|
419
|
-
* learning who can
|
|
417
|
+
* `GET /access`: the caller must resolve read on the thing being moved AND
|
|
418
|
+
* on the destination folder. The lists name people, and someone who cannot
|
|
419
|
+
* see a thing has no business learning who can; the `after` lists and the
|
|
420
|
+
* `changes` are the destination folder's rules read back (whom it grants,
|
|
421
|
+
* whom it denies by name), so a folder the caller may not open tells them
|
|
422
|
+
* nothing either.
|
|
423
|
+
*
|
|
424
|
+
* `changes` is who loses and who gains `read` or `write` in the move, as the
|
|
425
|
+
* resolver judges each candidate on both sides
|
|
426
|
+
* (`prospectiveHolderChanges`) — the one answer the move dialog and the
|
|
427
|
+
* agent's `move_file` both show. The two lists are not that answer: they
|
|
428
|
+
* name grants, so a writer leaving a folder everyone reads shows as losing
|
|
429
|
+
* `read` it keeps, and a person the destination denies by name is on
|
|
430
|
+
* neither list.
|
|
420
431
|
*/
|
|
421
432
|
router.get('/workspace/:id/access/prospective', async (req, res) => {
|
|
422
433
|
const user = await requireUser(req, res);
|
|
@@ -445,13 +456,21 @@ export function createAccessRoutes(
|
|
|
445
456
|
res.status(403).json({ error: 'You do not have access to this file.' });
|
|
446
457
|
return;
|
|
447
458
|
}
|
|
459
|
+
if (!(await accessControl.canRead(req.params.id, user.email, toDir))) {
|
|
460
|
+
res.status(403).json({ error: 'You do not have access to this folder.' });
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
448
463
|
|
|
449
464
|
const name = from.slice(from.lastIndexOf('/') + 1);
|
|
450
465
|
const to = toDir ? `${toDir}/${name}` : name;
|
|
451
|
-
const { before, after } = await
|
|
466
|
+
const [{ before, after }, changes] = await Promise.all([
|
|
467
|
+
accessControl.prospectiveHolders(req.params.id, from, to),
|
|
468
|
+
accessControl.prospectiveHolderChanges(req.params.id, from, to),
|
|
469
|
+
]);
|
|
452
470
|
res.json({
|
|
453
471
|
before: { read: holderPrincipals(before.read), write: holderPrincipals(before.write) },
|
|
454
472
|
after: { read: holderPrincipals(after.read), write: holderPrincipals(after.write) },
|
|
473
|
+
changes,
|
|
455
474
|
});
|
|
456
475
|
} catch (err) {
|
|
457
476
|
const { status, body } = toHttpError(err);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export type { IAccessControl } from './access-control.interface.js';
|
|
7
7
|
// Reached by other modules; exported here so they stop importing the files.
|
|
8
|
-
export type { AccessDecisionSource, AccessTargetKind } from './access-control.interface.js';
|
|
8
|
+
export type { AccessDecisionSource, AccessTargetKind, HolderChange, HolderChanges } from './access-control.interface.js';
|
|
9
9
|
export { accessMdPathForFolder, fileCarriesAccessRules, governingFolderOf } from './access-mutation.service.js';
|
|
10
10
|
export type { AccessMutationService, IAccessMutation, TargetKind } from './access-mutation.service.js';
|
|
11
11
|
export { folderTarget, rulesFileFor } from './access-requests.contract.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_KB_LAYOUT } from '@bevel-software/platform-shared';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { mergeGroupsIntoRoles, parseRolesYaml } from '../../access-model/
|
|
4
|
-
import { sharedFileRulesSection } from '../../agent-instructions/
|
|
3
|
+
import { mergeGroupsIntoRoles, parseRolesYaml } from '../../access-model/index.js';
|
|
4
|
+
import { sharedFileRulesSection } from '../../agent-instructions/index.js';
|
|
5
5
|
import {
|
|
6
6
|
CORE_SECTION_IDS,
|
|
7
7
|
PLATFORM_GUIDE_SEPARATOR,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
agentGuideSections,
|
|
10
10
|
composeAgentGuide,
|
|
11
11
|
coreAgentGuideSections,
|
|
12
|
+
guideStartText,
|
|
12
13
|
isAgentGuidePath,
|
|
13
14
|
isManagedGuide,
|
|
14
15
|
joinGuideSections,
|
|
@@ -44,11 +45,8 @@ describe('the guide is composed from the platform\'s sections', () => {
|
|
|
44
45
|
const rules = sections.find((s) => s.id === WORKING_WITH_FILES_SECTION_ID)!;
|
|
45
46
|
expect(rules.body).toBe(sharedFileRulesSection(DEFAULT_KB_LAYOUT));
|
|
46
47
|
expect(rules.literal).toBe(true);
|
|
47
|
-
// Every
|
|
48
|
-
for (const s of sections)
|
|
49
|
-
if (s.id === 'introduction') expect(s.body.startsWith('# Knowledge base')).toBe(true);
|
|
50
|
-
else expect(s.body.startsWith('## '), s.id).toBe(true);
|
|
51
|
-
}
|
|
48
|
+
// Every section begins with its heading, so the guide reads as one document.
|
|
49
|
+
for (const s of sections) expect(s.body.startsWith('## '), s.id).toBe(true);
|
|
52
50
|
// The order of the composed guide is the order of the sections — pinned
|
|
53
51
|
// exactly above by the id list, and by the join test below, which equates
|
|
54
52
|
// the composed guide with the sections joined.
|
|
@@ -72,12 +70,13 @@ describe('the guide is composed from the platform\'s sections', () => {
|
|
|
72
70
|
});
|
|
73
71
|
|
|
74
72
|
it('says it is served, not written, and names the one file it is read as', async () => {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
expect(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(
|
|
73
|
+
const guide0 = await composeAgentGuide(DEFAULT_KB_LAYOUT);
|
|
74
|
+
const start = section(guide0, '## Start here').replace(/\s+/g, ' ');
|
|
75
|
+
expect(start).toContain('`get_agent_guide({ body: { section: "all" } })` returns the whole guide');
|
|
76
|
+
// By id, not by heading: the section shows an AGENTS.md snippet whose own `##` heading sits in a code fence.
|
|
77
|
+
const finding = (await agentGuideSections(DEFAULT_KB_LAYOUT)).find((s) => s.id === 'how-agents-find-things')!.body.replace(/\s+/g, ' ');
|
|
78
|
+
expect(finding).toContain('`read_file` on `AGENTS.md` returns that file first, then the start of this guide');
|
|
79
|
+
expect(finding).toContain('never copy this guide into either: the platform serves it');
|
|
81
80
|
// The old header, which proved a file on disk was the platform's, is gone
|
|
82
81
|
// from the served text — or every read would look like a stale copy.
|
|
83
82
|
expect(isManagedGuide(await composeAgentGuide(DEFAULT_KB_LAYOUT))).toBe(false);
|
|
@@ -95,7 +94,7 @@ describe('the guide is composed from the platform\'s sections', () => {
|
|
|
95
94
|
it('serves the guide as sections with titles, the whole being the sections joined', async () => {
|
|
96
95
|
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
97
96
|
expect(sections.map((s) => s.id)).toEqual(CORE_SECTION_IDS);
|
|
98
|
-
expect(sections.find((s) => s.id === '
|
|
97
|
+
expect(sections.find((s) => s.id === 'start-here')!.title).toBe('Start here');
|
|
99
98
|
expect(sections.find((s) => s.id === 'where-a-new-file-goes')!.title).toBe('Where a new file goes');
|
|
100
99
|
expect(sections.find((s) => s.id === 'skills')!.title).toContain('Skills (`Skills/');
|
|
101
100
|
expect(joinGuideSections(sections)).toBe(await composeAgentGuide(DEFAULT_KB_LAYOUT));
|
|
@@ -120,13 +119,13 @@ describe('the guide is composed from the platform\'s sections', () => {
|
|
|
120
119
|
const hook = (sections: readonly AgentGuideSection[]) => [
|
|
121
120
|
...sections
|
|
122
121
|
.filter((s) => s.id !== 'conventions')
|
|
123
|
-
.map((s) => (s.id === '
|
|
122
|
+
.map((s) => (s.id === 'glossary' ? { ...s, body: '## Words used here\n\nAsk the graph.\n' } : s)),
|
|
124
123
|
{ id: 'knowledge-graph', body: '## Knowledge graph\n\nNodes live under `{{knowledgeBaseDir}}/<Ontology>/Knowledge/`.\n' },
|
|
125
124
|
];
|
|
126
125
|
const guide = await composeAgentGuide({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }, hook);
|
|
127
126
|
expect(guide).not.toContain('## Conventions');
|
|
128
|
-
expect(section(guide, '##
|
|
129
|
-
expect(section(guide, '##
|
|
127
|
+
expect(section(guide, '## Words used here')).toContain('Ask the graph.');
|
|
128
|
+
expect(section(guide, '## Words used here')).not.toContain('Plugin');
|
|
130
129
|
expect(guide.trimEnd().endsWith('Nodes live under `Docs/<Ontology>/Knowledge/`.')).toBe(true);
|
|
131
130
|
expect(guide).toContain(sharedFileRulesSection({ ...DEFAULT_KB_LAYOUT, knowledgeBaseDir: 'Docs' }));
|
|
132
131
|
// An async hook is awaited, and the layout it is handed is the resolved one.
|
|
@@ -138,6 +137,61 @@ describe('the guide is composed from the platform\'s sections', () => {
|
|
|
138
137
|
expect(seen).toEqual(['Docs', 'AGENTS.md']);
|
|
139
138
|
});
|
|
140
139
|
|
|
140
|
+
it('routes the start section to a section a distribution adds, when that section says what it answers', async () => {
|
|
141
|
+
const hook = (sections: readonly AgentGuideSection[]) => [
|
|
142
|
+
...sections,
|
|
143
|
+
{ id: 'graph-rules', body: '## Graph rules\n\nNodes carry an id.\n', asks: 'How is a graph node written? Which | fields?' },
|
|
144
|
+
{ id: 'quiet', body: '## Quiet\n\nNot routed.\n' },
|
|
145
|
+
// Already in the table: no second row.
|
|
146
|
+
{ id: 'tools', body: '## Tools again\n\nA second copy.\n', asks: 'When can I use a tool?' },
|
|
147
|
+
];
|
|
148
|
+
const start = (await agentGuideSections(DEFAULT_KB_LAYOUT, hook)).find((s) => s.id === 'start-here')!.body;
|
|
149
|
+
const rows = start.split('\n').filter((line) => line.startsWith('|'));
|
|
150
|
+
// After the table's last row, with a `|` in the question escaped.
|
|
151
|
+
expect(rows.at(-1)).toBe('| How is a graph node written? Which \\| fields? | `graph-rules` |');
|
|
152
|
+
expect(start).not.toContain('`quiet`');
|
|
153
|
+
expect(rows.filter((line) => line.endsWith('| `tools` |'))).toHaveLength(1);
|
|
154
|
+
// Core's own guide is left as it was.
|
|
155
|
+
const core = (await agentGuideSections(DEFAULT_KB_LAYOUT)).find((s) => s.id === 'start-here')!.body;
|
|
156
|
+
expect(core).not.toContain('graph-rules');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('serves a distribution\'s own version of a core section in core\'s place, when the hook adds it under the same id', async () => {
|
|
160
|
+
const hook = (sections: readonly AgentGuideSection[]) => [
|
|
161
|
+
...sections,
|
|
162
|
+
// The enterprise way: its own sections appended, two of them under core ids.
|
|
163
|
+
{ id: 'html-views', body: '## Pages, our way\n\nOurs.\n' },
|
|
164
|
+
{ id: 'start-here', body: '## Start here\n\nOurs first.\n\n| Question | Section |\n|---|---|\n| Pages? | `html-views` |\n' },
|
|
165
|
+
{ id: 'graph', body: '## Graph\n\nNodes.\n', asks: 'How is the graph written?' },
|
|
166
|
+
];
|
|
167
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, hook);
|
|
168
|
+
const core = await agentGuideSections(DEFAULT_KB_LAYOUT);
|
|
169
|
+
// One section per id, each where core's stood, the hook's text in it.
|
|
170
|
+
expect(sections.map((s) => s.id)).toEqual([...core.map((s) => s.id), 'graph']);
|
|
171
|
+
expect(sections.find((s) => s.id === 'html-views')!.body).toBe('## Pages, our way\n\nOurs.');
|
|
172
|
+
const start = sections.find((s) => s.id === 'start-here')!.body;
|
|
173
|
+
expect(start.startsWith('## Start here\n\nOurs first.')).toBe(true);
|
|
174
|
+
// The overriding start section is the one routed to: a row for the added section lands in ITS table.
|
|
175
|
+
expect(start).toContain('| How is the graph written? | `graph` |');
|
|
176
|
+
expect(start).not.toContain('`for-git-users`');
|
|
177
|
+
// The whole guide carries the override once, and never core's text under that id.
|
|
178
|
+
const guide = joinGuideSections(sections);
|
|
179
|
+
expect(guide.match(/^## Pages, our way$/gm)).toHaveLength(1);
|
|
180
|
+
expect(guide).not.toContain('## HTML views');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('answers a read of AGENTS.md with the start section and every section by id, not the whole guide', async () => {
|
|
184
|
+
const sections = await agentGuideSections(DEFAULT_KB_LAYOUT, undefined, { defaultBranch: 'main' });
|
|
185
|
+
const text = guideStartText(sections);
|
|
186
|
+
const start = sections.find((s) => s.id === 'start-here')!;
|
|
187
|
+
expect(text.startsWith(start.body)).toBe(true);
|
|
188
|
+
for (const s of sections) expect(text).toContain(`- \`${s.id}\`: ${s.title}`);
|
|
189
|
+
expect(text).not.toContain(sections.find((s) => s.id === 'access-control')!.body);
|
|
190
|
+
// A guide a distribution left without a start section is answered whole.
|
|
191
|
+
const without = sections.filter((s) => s.id !== 'start-here');
|
|
192
|
+
expect(guideStartText(without)).toBe(joinGuideSections(without));
|
|
193
|
+
});
|
|
194
|
+
|
|
141
195
|
it('refuses a hook that drops or empties the shared file rules, which every file tool points at', async () => {
|
|
142
196
|
await expect(
|
|
143
197
|
composeAgentGuide(DEFAULT_KB_LAYOUT, (sections) => sections.filter((s) => s.id !== WORKING_WITH_FILES_SECTION_ID)),
|
|
@@ -250,8 +304,8 @@ describe('what the guide tells an agent', () => {
|
|
|
250
304
|
expect(placement).toContain('Extensions/personal-<id>/');
|
|
251
305
|
// A skill's own bundled files are part of the skill and stay welcome, so the
|
|
252
306
|
// rule does not deter an agent from writing a COMPLETE skill.
|
|
253
|
-
expect(placement).toContain("each skill's own
|
|
254
|
-
expect(placement).toContain("
|
|
307
|
+
expect(placement).toContain("each skill's own files");
|
|
308
|
+
expect(placement).toContain("in that skill's folder");
|
|
255
309
|
expect(placement).toContain('never a note or any other document');
|
|
256
310
|
// A private request gets a question and the restrictable folder, in the
|
|
257
311
|
// deployment's own root name.
|
|
@@ -259,8 +313,8 @@ describe('what the guide tells an agent', () => {
|
|
|
259
313
|
expect(placement).toContain('restricted so only they can read it');
|
|
260
314
|
// And when the user insists, the agent declines, says why, and offers again.
|
|
261
315
|
expect(placement).toContain('If they insist on the personal plugin, decline');
|
|
262
|
-
expect(placement).toContain('
|
|
263
|
-
expect(placement).toContain('offer a place under `Docs/`
|
|
316
|
+
expect(placement).toContain('only they would ever find it, and never where the organisation looks for knowledge');
|
|
317
|
+
expect(placement).toContain('offer a place under `Docs/` again');
|
|
264
318
|
// Nothing tells the agent to move or flag documents already filed there.
|
|
265
319
|
expect(placement).not.toMatch(/\bmove (them|it|any)\b/);
|
|
266
320
|
});
|
|
@@ -281,8 +335,8 @@ describe('what the guide tells an agent', () => {
|
|
|
281
335
|
// The four places that introduce it: the `my_plugin` bullet, the sentence
|
|
282
336
|
// on moving a skill, the placement rule, and the `everyone` note.
|
|
283
337
|
const prose = guide.replace(/\s+/g, ' ');
|
|
284
|
-
expect(prose).toContain(
|
|
285
|
-
expect(prose).toContain('A skill moves from a personal plugin
|
|
338
|
+
expect(prose).toContain(`\`my_plugin\`: the user's personal plugin, \`${plugins}/personal-<id>/\`, created on first use.`);
|
|
339
|
+
expect(prose).toContain('A skill moves from a personal plugin to a shared one by moving its folder.');
|
|
286
340
|
expect(prose).toContain("A person's private skill goes in their personal plugin");
|
|
287
341
|
expect(prose).toContain(
|
|
288
342
|
`A person's personal plugin (\`${plugins}/personal-<id>/\`) grants its owner access and denies \`everyone\` outright`,
|
|
@@ -299,8 +353,8 @@ describe('what the guide tells an agent', () => {
|
|
|
299
353
|
expect(prose).toContain('add people to existing roles, and use a GROUP for a task- or team-scoped set of people');
|
|
300
354
|
});
|
|
301
355
|
|
|
302
|
-
it('describes delete_branch where it says what
|
|
303
|
-
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '###
|
|
356
|
+
it('describes delete_branch where it says what happens to a draft', async () => {
|
|
357
|
+
const text = section(await composeAgentGuide(DEFAULT_KB_LAYOUT), '### When a draft goes away');
|
|
304
358
|
const prose = text.replace(/\s+/g, ' ');
|
|
305
359
|
expect(prose).toContain('`delete_branch`');
|
|
306
360
|
expect(prose).toContain('Only its author');
|