@bevel-software/platform-core-backend 0.28.1 → 0.30.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 +15 -0
- package/agent-guide/skills.md +8 -0
- 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.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- 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 +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- 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/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- 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/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.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 +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- 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/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- 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/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.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/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- 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 +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- 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 +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- 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 +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- 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__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- 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 +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- 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/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -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 +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- 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/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- 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/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- 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 +174 -309
- 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 +26 -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 +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -8,6 +8,7 @@ import { isAbsence, type ITreeWalker, type WalkListener } from '../../shared/fs.
|
|
|
8
8
|
import { assertOneSpecPerLine, isGitTimeout, type IGitRunner } from '../../shared/git.contract.js';
|
|
9
9
|
import { hashEmail } from '../../shared/email-identity.js';
|
|
10
10
|
import { NodeGitRunner } from '../workflow/git/node-git-runner.js';
|
|
11
|
+
import { readRevSha } from '../kb-fs/index.js';
|
|
11
12
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
12
13
|
import type {
|
|
13
14
|
IAccessControl,
|
|
@@ -66,19 +67,35 @@ import {
|
|
|
66
67
|
DIRECTORY_SYNC_BOT_NAME,
|
|
67
68
|
} from './directory-sync-bot.js';
|
|
68
69
|
|
|
70
|
+
/** A full object id, which is what every at-ref gate resolves its ref to before reading. */
|
|
71
|
+
function isCommitSha(ref: string): boolean {
|
|
72
|
+
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(ref);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What one spec of a batched read came back as: a blob's text, nothing at
|
|
77
|
+
* that path, or an object that is not a file (a directory, say). The last
|
|
78
|
+
* two are told apart on purpose: for the model at a commit, a path that is
|
|
79
|
+
* there but is not a file is not "absent", it is config the model cannot be
|
|
80
|
+
* read from.
|
|
81
|
+
*/
|
|
82
|
+
type CatFileOutcome =
|
|
83
|
+
| { kind: 'blob'; text: string }
|
|
84
|
+
| { kind: 'missing' }
|
|
85
|
+
| { kind: 'non-blob'; type: string };
|
|
86
|
+
|
|
69
87
|
/**
|
|
70
88
|
* Read many objects from a git repo in ONE `git cat-file --batch` process.
|
|
71
89
|
* `specs` are `<ref>:<path>` lines; the result array is index-aligned with
|
|
72
|
-
* them
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* (the spec may itself contain spaces, hence the endsWith checks).
|
|
90
|
+
* them. Output protocol per request: `<oid> <type> <size>\n<size bytes>\n`,
|
|
91
|
+
* or `<spec> missing\n` (the spec may itself contain spaces, hence the
|
|
92
|
+
* endsWith checks).
|
|
76
93
|
*/
|
|
77
|
-
async function
|
|
94
|
+
async function catFileBatchOutcomes(
|
|
78
95
|
runner: IGitRunner,
|
|
79
96
|
repoDir: string,
|
|
80
97
|
specs: string[],
|
|
81
|
-
): Promise<
|
|
98
|
+
): Promise<CatFileOutcome[]> {
|
|
82
99
|
// Bytes, not text: every `<size>` in the header counts bytes, and the walk
|
|
83
100
|
// below has to step by that count through content that is not all ASCII.
|
|
84
101
|
// One spec per LINE is the protocol, and this walk depends on getting back
|
|
@@ -91,7 +108,7 @@ async function catFileBatch(
|
|
|
91
108
|
input: `${specs.join('\n')}\n`,
|
|
92
109
|
encoding: 'buffer',
|
|
93
110
|
});
|
|
94
|
-
const results:
|
|
111
|
+
const results: CatFileOutcome[] = [];
|
|
95
112
|
let off = 0;
|
|
96
113
|
for (let i = 0; i < specs.length; i++) {
|
|
97
114
|
const nl = out.indexOf(0x0a, off);
|
|
@@ -99,7 +116,7 @@ async function catFileBatch(
|
|
|
99
116
|
const header = out.subarray(off, nl).toString('utf-8');
|
|
100
117
|
off = nl + 1;
|
|
101
118
|
if (header.endsWith(' missing') || header.endsWith(' ambiguous')) {
|
|
102
|
-
results.push(
|
|
119
|
+
results.push({ kind: 'missing' });
|
|
103
120
|
continue;
|
|
104
121
|
}
|
|
105
122
|
const parts = header.split(' ');
|
|
@@ -107,14 +124,33 @@ async function catFileBatch(
|
|
|
107
124
|
if (parts.length !== 3 || !Number.isInteger(size) || size < 0) {
|
|
108
125
|
throw new Error(`unexpected git cat-file header: ${header}`);
|
|
109
126
|
}
|
|
110
|
-
//
|
|
111
|
-
// payload still has to be skipped to stay aligned.
|
|
112
|
-
results.push(
|
|
127
|
+
// A non-blob (a directory path resolves to a tree) is reported as such,
|
|
128
|
+
// and its payload still has to be skipped to stay aligned.
|
|
129
|
+
results.push(
|
|
130
|
+
parts[1] === 'blob'
|
|
131
|
+
? { kind: 'blob', text: out.subarray(off, off + size).toString('utf-8') }
|
|
132
|
+
: { kind: 'non-blob', type: parts[1]! },
|
|
133
|
+
);
|
|
113
134
|
off += size + 1; // payload + trailing LF
|
|
114
135
|
}
|
|
115
136
|
return results;
|
|
116
137
|
}
|
|
117
138
|
|
|
139
|
+
/**
|
|
140
|
+
* {@link catFileBatchOutcomes} for callers that want text or nothing: the
|
|
141
|
+
* blob's text, or null when the spec is missing at the ref or resolves to a
|
|
142
|
+
* non-blob — the own-entries reads, where a directory where a file was
|
|
143
|
+
* named simply carries no entries.
|
|
144
|
+
*/
|
|
145
|
+
async function catFileBatch(
|
|
146
|
+
runner: IGitRunner,
|
|
147
|
+
repoDir: string,
|
|
148
|
+
specs: string[],
|
|
149
|
+
): Promise<(string | null)[]> {
|
|
150
|
+
const outcomes = await catFileBatchOutcomes(runner, repoDir, specs);
|
|
151
|
+
return outcomes.map((o) => (o.kind === 'blob' ? o.text : null));
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
|
|
119
155
|
interface AccessModel {
|
|
120
156
|
roles: RolesIndex;
|
|
@@ -1340,7 +1376,13 @@ function ineligibleNamedEmailsResolved(
|
|
|
1340
1376
|
* was built from (not the ref name that led there), so the per-file
|
|
1341
1377
|
* own-entries reads that follow a gate resolve against exactly that tree.
|
|
1342
1378
|
*/
|
|
1343
|
-
|
|
1379
|
+
/**
|
|
1380
|
+
* A model built at a commit, with the commit it was built at and, when the
|
|
1381
|
+
* tree is not too large to hold, every path the commit carries: what the
|
|
1382
|
+
* own-entries reads need to tell a path that is absent at the commit (a
|
|
1383
|
+
* fact, remembered) from a blob the batch could not read (never remembered).
|
|
1384
|
+
*/
|
|
1385
|
+
type AtRefModel = { model: AccessModel; resolvedRef: string; tree: ReadonlySet<string> | null };
|
|
1344
1386
|
/**
|
|
1345
1387
|
* One build's outcome: a model, no roles.yaml at the commit, or one that
|
|
1346
1388
|
* doesn't parse. A git read failure is none of these: it throws
|
|
@@ -1384,22 +1426,50 @@ export class AccessControlService implements IAccessControl {
|
|
|
1384
1426
|
*
|
|
1385
1427
|
* This is what makes the change-request gates affordable. One detail read
|
|
1386
1428
|
* plus one approval click runs six at-ref gates, and before this cache
|
|
1387
|
-
* every one of them rebuilt the model from git
|
|
1388
|
-
* `
|
|
1389
|
-
*
|
|
1429
|
+
* every one of them rebuilt the model from git. A build is two git calls
|
|
1430
|
+
* (`buildModelAtCommit`: one `ls-tree`, one `cat-file --batch` for every
|
|
1431
|
+
* file the model reads), and each call forks the server process, which
|
|
1432
|
+
* costs tens of milliseconds on a server holding a large heap.
|
|
1433
|
+
*
|
|
1434
|
+
* Bounded FIFO. The bound is sized for a listing that resolves access at
|
|
1435
|
+
* every open change request's two refs: one commit per open request plus
|
|
1436
|
+
* the targets, and a knowledge base with a hundred open requests was
|
|
1437
|
+
* evicting in a cycle under the old bound of 64, rebuilding every model on
|
|
1438
|
+
* every listing — thousands of forks, minutes of the main thread, and
|
|
1439
|
+
* every other request waiting behind them. A commit's tree never changes,
|
|
1440
|
+
* so a model is good for as long as it is held; the bound only caps memory.
|
|
1390
1441
|
*
|
|
1391
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
1396
|
-
*
|
|
1397
|
-
*
|
|
1398
|
-
* misses on the same commit share one build via `atRefInFlight`.
|
|
1442
|
+
* A commit with no roles.yaml is remembered too (`atRefNoRoles`): the batch
|
|
1443
|
+
* read answers "missing" for it from git itself, which is as final as the
|
|
1444
|
+
* commit. What is never cached is a build that saw a git read FAIL: it
|
|
1445
|
+
* throws AccessUnreadableError and the gate fails closed for that call, and
|
|
1446
|
+
* pinning that to a commit would turn one hiccup into a wrong verdict until
|
|
1447
|
+
* the branch moved. Concurrent misses on the same commit share one build
|
|
1448
|
+
* via `atRefInFlight`.
|
|
1399
1449
|
*/
|
|
1400
1450
|
private readonly atRefCache = new Map<string, AtRefModel>();
|
|
1451
|
+
private readonly atRefNoRoles = new Set<string>();
|
|
1401
1452
|
private readonly atRefInFlight = new Map<string, Promise<AtRefBuild>>();
|
|
1402
|
-
private static readonly AT_REF_CACHE_MAX =
|
|
1453
|
+
private static readonly AT_REF_CACHE_MAX = 512;
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* A path's own access entries AT A COMMIT, keyed by commit + path. A blob
|
|
1457
|
+
* at a commit never changes, so neither does what it declares for itself:
|
|
1458
|
+
* no TTL, no invalidation, and no workspace in the key (every clone holds
|
|
1459
|
+
* the same objects). This is the read every at-ref gate makes after the
|
|
1460
|
+
* model: one `cat-file --batch` per call for the paths asked, which a
|
|
1461
|
+
* change-request listing made once per ref per listing for the same paths
|
|
1462
|
+
* at the same commits. Bounded FIFO; the bound only caps memory.
|
|
1463
|
+
*/
|
|
1464
|
+
private readonly ownEntriesAtCommit = new Map<string, OwnEntries | null>();
|
|
1465
|
+
private static readonly OWN_ENTRIES_AT_COMMIT_MAX = 16_384;
|
|
1466
|
+
/** Paths a cached model keeps of its commit's tree, at most; a larger tree is not kept. */
|
|
1467
|
+
private static readonly TREE_INDEX_MAX_PATHS = 20_000;
|
|
1468
|
+
/** Paths held across every cached model's tree, at most; past it the oldest models let their tree go. */
|
|
1469
|
+
private static readonly TREE_INDEX_BUDGET = 500_000;
|
|
1470
|
+
private readonly treeIndexMaxPaths: number;
|
|
1471
|
+
private readonly treeIndexBudget: number;
|
|
1472
|
+
private treePathsHeld = 0;
|
|
1403
1473
|
|
|
1404
1474
|
/**
|
|
1405
1475
|
* Canonicalised deployment-owner emails (see `AccessModel.deploymentOwners`).
|
|
@@ -1434,10 +1504,19 @@ export class AccessControlService implements IAccessControl {
|
|
|
1434
1504
|
* run on the default layout.
|
|
1435
1505
|
*/
|
|
1436
1506
|
private readonly kb: Pick<KbContext, 'layout'> = { layout: DEFAULT_KB_LAYOUT },
|
|
1507
|
+
/**
|
|
1508
|
+
* How much of the commits' trees the at-ref models may hold (see
|
|
1509
|
+
* `TREE_INDEX_MAX_PATHS`, `TREE_INDEX_BUDGET`). The defaults are the
|
|
1510
|
+
* deployment's; a suite lowers them to reach the bounds with a small
|
|
1511
|
+
* repository.
|
|
1512
|
+
*/
|
|
1513
|
+
limits: { treeIndexMaxPaths?: number; treeIndexBudget?: number } = {},
|
|
1437
1514
|
) {
|
|
1438
1515
|
this.deploymentOwners = new Set(
|
|
1439
1516
|
deploymentOwners.filter(Boolean).map((e) => canonicalEmail(e)),
|
|
1440
1517
|
);
|
|
1518
|
+
this.treeIndexMaxPaths = limits.treeIndexMaxPaths ?? AccessControlService.TREE_INDEX_MAX_PATHS;
|
|
1519
|
+
this.treeIndexBudget = limits.treeIndexBudget ?? AccessControlService.TREE_INDEX_BUDGET;
|
|
1441
1520
|
}
|
|
1442
1521
|
|
|
1443
1522
|
/**
|
|
@@ -1579,6 +1658,23 @@ export class AccessControlService implements IAccessControl {
|
|
|
1579
1658
|
return result;
|
|
1580
1659
|
}
|
|
1581
1660
|
|
|
1661
|
+
async groupsOfUser(workspaceId: string, userEmail: string): Promise<string[]> {
|
|
1662
|
+
const model = await this.loadModel(workspaceId);
|
|
1663
|
+
const email = canonicalEmail(userEmail);
|
|
1664
|
+
const names: string[] = [];
|
|
1665
|
+
for (const principal of model.roles.byCanonical.values()) {
|
|
1666
|
+
if (principal.kind === 'group' && principal.emails.has(email)) names.push(principal.displayName);
|
|
1667
|
+
}
|
|
1668
|
+
return names;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
async visibleGroups(workspaceId: string, userEmail: string, groups: readonly string[]): Promise<string[]> {
|
|
1672
|
+
if (groups.length === 0) return [];
|
|
1673
|
+
if (await this.holdsAdminRootWrite(workspaceId, userEmail)) return [...groups];
|
|
1674
|
+
const mine = new Set(await this.groupsOfUser(workspaceId, userEmail));
|
|
1675
|
+
return groups.filter((g) => mine.has(g));
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1582
1678
|
async canWriteBatch(
|
|
1583
1679
|
workspaceId: string,
|
|
1584
1680
|
userEmail: string,
|
|
@@ -2096,7 +2192,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2096
2192
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2097
2193
|
if (!loaded) return null;
|
|
2098
2194
|
const repoDir = await this.repoDir(workspaceId);
|
|
2099
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2195
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2100
2196
|
return hasPermissionResolved(loaded.model, 'write', userEmail, relativePath, own);
|
|
2101
2197
|
}
|
|
2102
2198
|
|
|
@@ -2109,7 +2205,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2109
2205
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2110
2206
|
if (!loaded) return null;
|
|
2111
2207
|
const repoDir = await this.repoDir(workspaceId);
|
|
2112
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2208
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2113
2209
|
return canReadResolved(loaded.model, userEmail, relativePath, own);
|
|
2114
2210
|
}
|
|
2115
2211
|
|
|
@@ -2132,7 +2228,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2132
2228
|
const repoDir = await this.repoDir(workspaceId);
|
|
2133
2229
|
// One `git cat-file --batch` for the whole path set — a per-path `git
|
|
2134
2230
|
// show` spawn made CR owner-routing take minutes on large change sets.
|
|
2135
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2231
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2136
2232
|
const result = new Map<string, boolean>();
|
|
2137
2233
|
for (const p of relativePaths) {
|
|
2138
2234
|
const machineOwned = machineAnswers.get(p);
|
|
@@ -2157,7 +2253,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2157
2253
|
if (!loaded) return null;
|
|
2158
2254
|
const repoDir = await this.repoDir(workspaceId);
|
|
2159
2255
|
// One `git cat-file --batch` for the whole set — see canWriteBatchAtRef.
|
|
2160
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2256
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2161
2257
|
const result = new Map<string, boolean>();
|
|
2162
2258
|
for (const p of relativePaths) {
|
|
2163
2259
|
result.set(p, canReadResolved(loaded.model, userEmail, p, owns.get(p) ?? null));
|
|
@@ -2180,7 +2276,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2180
2276
|
const loaded = await this.loadModelAtRef(workspaceId, ref);
|
|
2181
2277
|
if (!loaded) return null;
|
|
2182
2278
|
const repoDir = await this.repoDir(workspaceId);
|
|
2183
|
-
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath);
|
|
2279
|
+
const own = await this.readOwnEntriesAtRef(repoDir, loaded.resolvedRef, relativePath, loaded.tree);
|
|
2184
2280
|
return eligibleHoldersResolved(loaded.model, 'write', relativePath, own);
|
|
2185
2281
|
}
|
|
2186
2282
|
|
|
@@ -2294,7 +2390,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2294
2390
|
if (!loaded) return null;
|
|
2295
2391
|
const repoDir = await this.repoDir(workspaceId);
|
|
2296
2392
|
// One `git cat-file --batch` for the whole path set — see canWriteBatchAtRef.
|
|
2297
|
-
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths);
|
|
2393
|
+
const owns = await this.readOwnEntriesAtRefBatch(repoDir, loaded.resolvedRef, relativePaths, loaded.tree);
|
|
2298
2394
|
for (const p of relativePaths) {
|
|
2299
2395
|
if (result.has(p)) continue;
|
|
2300
2396
|
const own = owns.get(p) ?? null;
|
|
@@ -2512,8 +2608,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2512
2608
|
repoDir: string,
|
|
2513
2609
|
ref: string,
|
|
2514
2610
|
relativePath: string,
|
|
2611
|
+
tree: ReadonlySet<string> | null = null,
|
|
2515
2612
|
): Promise<OwnEntries | null> {
|
|
2516
2613
|
if (relativePath === 'roles.yaml') return null;
|
|
2614
|
+
// At a commit (what every gate passes: `resolvedRef`), the batched read
|
|
2615
|
+
// and its memo answer; a single `git show` stays for a symbolic ref.
|
|
2616
|
+
if (isCommitSha(ref)) {
|
|
2617
|
+
return (await this.readOwnEntriesAtRefBatch(repoDir, ref, [relativePath], tree)).get(relativePath) ?? null;
|
|
2618
|
+
}
|
|
2517
2619
|
const text = await this.showAtRef(repoDir, ref, relativePath);
|
|
2518
2620
|
if (text === null) return null;
|
|
2519
2621
|
if (isAccessMdPath(relativePath)) return accessMdSelfEntries(text);
|
|
@@ -2532,23 +2634,60 @@ export class AccessControlService implements IAccessControl {
|
|
|
2532
2634
|
repoDir: string,
|
|
2533
2635
|
ref: string,
|
|
2534
2636
|
relativePaths: string[],
|
|
2637
|
+
tree: ReadonlySet<string> | null = null,
|
|
2535
2638
|
): Promise<Map<string, OwnEntries | null>> {
|
|
2536
2639
|
const result = new Map<string, OwnEntries | null>();
|
|
2537
2640
|
const wanted: string[] = [];
|
|
2641
|
+
const memo = isCommitSha(ref) ? this.ownEntriesAtCommit : null;
|
|
2538
2642
|
for (const p of relativePaths) {
|
|
2539
|
-
if (p === 'roles.yaml')
|
|
2540
|
-
|
|
2643
|
+
if (p === 'roles.yaml') {
|
|
2644
|
+
result.set(p, null);
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2647
|
+
const key = `${ref}\0${p}`;
|
|
2648
|
+
if (memo?.has(key)) {
|
|
2649
|
+
result.set(p, memo.get(key) ?? null);
|
|
2650
|
+
continue;
|
|
2651
|
+
}
|
|
2652
|
+
// A path the commit's tree does not carry has no own entries, as a
|
|
2653
|
+
// fact about the commit: remembered, and not read. (A change request
|
|
2654
|
+
// that adds a file asks about it at the base, where it does not exist
|
|
2655
|
+
// yet; one that deletes a file asks at the head.)
|
|
2656
|
+
if (tree && !tree.has(p)) {
|
|
2657
|
+
result.set(p, null);
|
|
2658
|
+
if (memo) this.rememberOwnEntriesAtCommit(key, null);
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (!wanted.includes(p)) wanted.push(p);
|
|
2541
2662
|
}
|
|
2542
2663
|
if (wanted.length === 0) return result;
|
|
2543
2664
|
const texts = await catFileBatch(this.gitRunner, repoDir, wanted.map((p) => `${ref}:${p}`));
|
|
2544
2665
|
wanted.forEach((p, i) => {
|
|
2545
2666
|
const text = texts[i];
|
|
2546
|
-
if (text === null)
|
|
2547
|
-
|
|
2667
|
+
if (text === null) {
|
|
2668
|
+
// Absent at the commit (when no tree was there to say so first), or
|
|
2669
|
+
// a blob git could not read: the batch says "missing" for both, and
|
|
2670
|
+
// only the first is a fact about the commit. Answered as no own
|
|
2671
|
+
// entries, as before, but never remembered.
|
|
2672
|
+
result.set(p, null);
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
const own = isAccessMdPath(p) ? accessMdSelfEntries(text) : parseOwnAccessEntries(text);
|
|
2676
|
+
result.set(p, own);
|
|
2677
|
+
// What a blob declares for itself — including nothing — is a fact about the commit.
|
|
2678
|
+
if (memo) this.rememberOwnEntriesAtCommit(`${ref}\0${p}`, own);
|
|
2548
2679
|
});
|
|
2549
2680
|
return result;
|
|
2550
2681
|
}
|
|
2551
2682
|
|
|
2683
|
+
private rememberOwnEntriesAtCommit(key: string, value: OwnEntries | null): void {
|
|
2684
|
+
this.ownEntriesAtCommit.set(key, value);
|
|
2685
|
+
if (this.ownEntriesAtCommit.size > AccessControlService.OWN_ENTRIES_AT_COMMIT_MAX) {
|
|
2686
|
+
const oldest = this.ownEntriesAtCommit.keys().next().value;
|
|
2687
|
+
if (oldest !== undefined) this.ownEntriesAtCommit.delete(oldest);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2552
2691
|
/**
|
|
2553
2692
|
* Resolve a candidate ref to one git accepts. Falls back to `origin/<ref>`
|
|
2554
2693
|
* for short branch names, matching the heuristic in
|
|
@@ -2600,28 +2739,38 @@ export class AccessControlService implements IAccessControl {
|
|
|
2600
2739
|
/**
|
|
2601
2740
|
* List every `access.md` path that exists at any depth as of `ref`.
|
|
2602
2741
|
* The access tree is structure-agnostic: any `access.md` participates
|
|
2603
|
-
* regardless of where it sits. Uses `git ls-tree -r --name-only
|
|
2604
|
-
*
|
|
2605
|
-
*
|
|
2742
|
+
* regardless of where it sits. Uses `git ls-tree -r -z --name-only`: `-z`
|
|
2743
|
+
* puts a NUL between paths and prints them as they are, where the default
|
|
2744
|
+
* output C-quotes a path with a non-ASCII or special character, which would
|
|
2745
|
+
* then name nothing when read back. `'error'` when git failed: a model
|
|
2746
|
+
* built without its access files is not the model, and the caller must not
|
|
2747
|
+
* cache it as one.
|
|
2606
2748
|
*/
|
|
2607
2749
|
private async listAccessFilesAtRef(
|
|
2608
2750
|
repoDir: string,
|
|
2609
2751
|
ref: string,
|
|
2610
|
-
): Promise<{ accessFiles: string[]; pluginDirs: string[] } | 'error'> {
|
|
2752
|
+
): Promise<{ accessFiles: string[]; pluginDirs: string[]; named: Set<string>; all: string[] } | 'error'> {
|
|
2611
2753
|
try {
|
|
2612
|
-
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '--name-only', ref]);
|
|
2754
|
+
const { stdout } = await this.gitRunner.run(repoDir, ['ls-tree', '-r', '-z', '--name-only', ref]);
|
|
2613
2755
|
const out: string[] = [];
|
|
2614
2756
|
const pluginDirs: string[] = [];
|
|
2757
|
+
const all: string[] = [];
|
|
2758
|
+
// The fixed-name sources the tree carries (roles.yaml, the groups
|
|
2759
|
+
// files): a read of one of these that comes back empty is "absent"
|
|
2760
|
+
// only when the tree does not name it.
|
|
2761
|
+
const named = new Set<string>();
|
|
2615
2762
|
const { pluginsDir } = this.kb.layout;
|
|
2616
|
-
for (const
|
|
2617
|
-
const p = line.trim();
|
|
2763
|
+
for (const p of stdout.split('\0')) {
|
|
2618
2764
|
if (!p) continue;
|
|
2765
|
+
// One past the cap is enough to know the tree is not kept.
|
|
2766
|
+
if (all.length <= this.treeIndexMaxPaths) all.push(p);
|
|
2619
2767
|
if (p === 'access.md' || p.endsWith('/access.md')) out.push(p);
|
|
2768
|
+
else if (p === 'roles.yaml' || p === SYNCED_GROUPS_YAML || p === GROUPS_YAML) named.add(p);
|
|
2620
2769
|
else if (p.startsWith(`${pluginsDir}/`) && p.endsWith(`/${PLUGIN_MANIFEST_FILE}`)) {
|
|
2621
2770
|
pluginDirs.push(p.slice(0, -(PLUGIN_MANIFEST_FILE.length + 1)));
|
|
2622
2771
|
}
|
|
2623
2772
|
}
|
|
2624
|
-
return { accessFiles: out, pluginDirs };
|
|
2773
|
+
return { accessFiles: out, pluginDirs, named, all };
|
|
2625
2774
|
} catch {
|
|
2626
2775
|
return 'error';
|
|
2627
2776
|
}
|
|
@@ -2666,6 +2815,9 @@ export class AccessControlService implements IAccessControl {
|
|
|
2666
2815
|
const key = `${workspaceId}\0${commit}`;
|
|
2667
2816
|
const hit = this.atRefCache.get(key);
|
|
2668
2817
|
if (hit) return hit;
|
|
2818
|
+
// A commit known to carry no roles.yaml: the next candidate, without a
|
|
2819
|
+
// build (see `atRefNoRoles`).
|
|
2820
|
+
if (this.atRefNoRoles.has(key)) continue;
|
|
2669
2821
|
// Registered BEFORE the first await on the build path, so concurrent
|
|
2670
2822
|
// misses on one commit share a single build instead of all racing past
|
|
2671
2823
|
// an empty in-flight check together.
|
|
@@ -2674,6 +2826,7 @@ export class AccessControlService implements IAccessControl {
|
|
|
2674
2826
|
pending = this.buildModelAtCommit(repoDir, commit, candidate)
|
|
2675
2827
|
.then((built) => {
|
|
2676
2828
|
if (typeof built !== 'string') this.rememberAtRef(key, built);
|
|
2829
|
+
else if (built === 'no-roles') this.rememberNoRolesAtRef(key);
|
|
2677
2830
|
return built;
|
|
2678
2831
|
})
|
|
2679
2832
|
.finally(() => {
|
|
@@ -2692,6 +2845,14 @@ export class AccessControlService implements IAccessControl {
|
|
|
2692
2845
|
/** The commit `ref` points at in this clone, or null when it doesn't resolve. */
|
|
2693
2846
|
private async revParseCommit(repoDir: string, ref: string): Promise<string | null> {
|
|
2694
2847
|
if (!ref || ref.startsWith('-')) return null;
|
|
2848
|
+
// The ref files first (`readRevSha`): the names git would try, in git's
|
|
2849
|
+
// order, read without a process. "Missing" from the files is what
|
|
2850
|
+
// rev-parse answers nothing for; "unreadable" — a spelling the reader
|
|
2851
|
+
// does not resolve, a tag it cannot peel, a file it could not read — is
|
|
2852
|
+
// git's to decide, as the authority.
|
|
2853
|
+
const fromFiles = await readRevSha(repoDir, ref);
|
|
2854
|
+
if (fromFiles === 'missing') return null;
|
|
2855
|
+
if (fromFiles !== 'unreadable') return fromFiles.sha;
|
|
2695
2856
|
try {
|
|
2696
2857
|
const { stdout } = await this.gitRunner.run(repoDir, [
|
|
2697
2858
|
'rev-parse',
|
|
@@ -2712,9 +2873,30 @@ export class AccessControlService implements IAccessControl {
|
|
|
2712
2873
|
|
|
2713
2874
|
private rememberAtRef(key: string, value: AtRefModel): void {
|
|
2714
2875
|
this.atRefCache.set(key, value);
|
|
2876
|
+
this.treePathsHeld += value.tree?.size ?? 0;
|
|
2715
2877
|
if (this.atRefCache.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2716
2878
|
const oldest = this.atRefCache.keys().next().value;
|
|
2717
|
-
if (oldest !== undefined)
|
|
2879
|
+
if (oldest !== undefined) {
|
|
2880
|
+
this.treePathsHeld -= this.atRefCache.get(oldest)?.tree?.size ?? 0;
|
|
2881
|
+
this.atRefCache.delete(oldest);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
// The trees are a budget of their own, across every held model: past
|
|
2885
|
+
// it, the oldest models keep their model and let their tree go — the
|
|
2886
|
+
// gate still answers, absence is just no longer remembered for them.
|
|
2887
|
+
for (const held of this.atRefCache.values()) {
|
|
2888
|
+
if (this.treePathsHeld <= this.treeIndexBudget) break;
|
|
2889
|
+
if (!held.tree) continue;
|
|
2890
|
+
this.treePathsHeld -= held.tree.size;
|
|
2891
|
+
held.tree = null;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
private rememberNoRolesAtRef(key: string): void {
|
|
2896
|
+
this.atRefNoRoles.add(key);
|
|
2897
|
+
if (this.atRefNoRoles.size > AccessControlService.AT_REF_CACHE_MAX) {
|
|
2898
|
+
const oldest = this.atRefNoRoles.values().next().value;
|
|
2899
|
+
if (oldest !== undefined) this.atRefNoRoles.delete(oldest);
|
|
2718
2900
|
}
|
|
2719
2901
|
}
|
|
2720
2902
|
|
|
@@ -2725,6 +2907,15 @@ export class AccessControlService implements IAccessControl {
|
|
|
2725
2907
|
* means the commit carries no roles.yaml (the caller tries its next
|
|
2726
2908
|
* candidate); `'malformed'` means it does but it doesn't parse (the caller
|
|
2727
2909
|
* answers null, as it always has).
|
|
2910
|
+
*
|
|
2911
|
+
* TWO git calls, whatever the tree holds: one `ls-tree` to learn which
|
|
2912
|
+
* files the model is made of, then one `cat-file --batch` that reads all of
|
|
2913
|
+
* them — roles.yaml, the groups sources, every access.md and every plugin
|
|
2914
|
+
* manifest. It used to be one `git show` per file, and each git call forks
|
|
2915
|
+
* the server process, which on a server holding a large heap costs tens of
|
|
2916
|
+
* milliseconds of the main thread per call: a knowledge base with a few
|
|
2917
|
+
* dozen access files paid a second or more per commit, times every ref a
|
|
2918
|
+
* listing resolves.
|
|
2728
2919
|
*/
|
|
2729
2920
|
private async buildModelAtCommit(
|
|
2730
2921
|
repoDir: string,
|
|
@@ -2737,20 +2928,74 @@ export class AccessControlService implements IAccessControl {
|
|
|
2737
2928
|
// and the caller fails closed (AccessUnreadableError, a 503) until a
|
|
2738
2929
|
// retry reads the tree in full.
|
|
2739
2930
|
const tag = `${label}@${commit.slice(0, 7)}`;
|
|
2931
|
+
|
|
2932
|
+
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2933
|
+
if (listed === 'error') {
|
|
2934
|
+
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2935
|
+
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2936
|
+
}
|
|
2937
|
+
const wanted = [
|
|
2938
|
+
'roles.yaml',
|
|
2939
|
+
SYNCED_GROUPS_YAML,
|
|
2940
|
+
GROUPS_YAML,
|
|
2941
|
+
...listed.accessFiles,
|
|
2942
|
+
...listed.pluginDirs.map((dir) => `${dir}/${PLUGIN_MANIFEST_FILE}`),
|
|
2943
|
+
];
|
|
2944
|
+
let outcomes: CatFileOutcome[];
|
|
2945
|
+
try {
|
|
2946
|
+
outcomes = await catFileBatchOutcomes(this.gitRunner, repoDir, wanted.map((p) => `${commit}:${p}`));
|
|
2947
|
+
} catch (err) {
|
|
2948
|
+
// The operational signal: one line per failed build, naming the commit,
|
|
2949
|
+
// so a run of these is visible in the logs.
|
|
2950
|
+
logger(`access@${tag}`).warn(
|
|
2951
|
+
`git read of the access tree failed; refusing to decide from a partial tree: ${err instanceof Error ? err.message : String(err)}`,
|
|
2952
|
+
);
|
|
2953
|
+
throw new AccessUnreadableError(label, 'access tree (cat-file)');
|
|
2954
|
+
}
|
|
2955
|
+
const fetched = new Map<string, CatFileOutcome>();
|
|
2956
|
+
wanted.forEach((p, i) => fetched.set(p, outcomes[i] ?? { kind: 'missing' }));
|
|
2957
|
+
const refuse = (relativePath: string, why: string): never => {
|
|
2958
|
+
logger(`access@${tag}`).warn(`${relativePath} ${why}; refusing to decide from a partial tree`);
|
|
2959
|
+
throw new AccessUnreadableError(label, relativePath);
|
|
2960
|
+
};
|
|
2961
|
+
// What the build reads. "Missing" from the batch is "absent" only for a
|
|
2962
|
+
// groups source the tree does not name. A path the tree names — an
|
|
2963
|
+
// access.md, a plugin manifest, or a groups file the listing carried —
|
|
2964
|
+
// that comes back missing has a blob git could not read (a lost object),
|
|
2965
|
+
// and one that is not a file is a tree the model cannot be read from:
|
|
2966
|
+
// either way the build fails closed rather than drop what that file
|
|
2967
|
+
// would deny. A path the build did not prefetch (none today) is read on
|
|
2968
|
+
// its own, told apart from a git failure as before.
|
|
2969
|
+
const inTree = (relativePath: string) =>
|
|
2970
|
+
listed.named.has(relativePath) || !(relativePath === SYNCED_GROUPS_YAML || relativePath === GROUPS_YAML);
|
|
2740
2971
|
const read = async (relativePath: string): Promise<string | null> => {
|
|
2741
|
-
const
|
|
2742
|
-
if (
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
throw new AccessUnreadableError(label, relativePath);
|
|
2972
|
+
const got = fetched.get(relativePath);
|
|
2973
|
+
if (got === undefined) {
|
|
2974
|
+
const outcome = await this.showAtRefOutcome(repoDir, commit, relativePath);
|
|
2975
|
+
if (outcome.kind === 'error') refuse(relativePath, 'could not be read');
|
|
2976
|
+
return outcome.kind === 'text' ? outcome.text : null;
|
|
2747
2977
|
}
|
|
2748
|
-
|
|
2978
|
+
if (got.kind === 'blob') return got.text;
|
|
2979
|
+
if (got.kind === 'non-blob') return refuse(relativePath, `is a ${got.type}, not a file`);
|
|
2980
|
+
if (!inTree(relativePath)) return null;
|
|
2981
|
+
return refuse(relativePath, 'is in the tree but its blob could not be read');
|
|
2749
2982
|
};
|
|
2750
2983
|
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2984
|
+
// roles.yaml decides the build's outcome, so its states are read
|
|
2985
|
+
// directly. Absent from the tree is "no roles here" (the caller tries
|
|
2986
|
+
// its next candidate ref). In the tree but unreadable refuses the build:
|
|
2987
|
+
// answering "no roles" for it would be remembered for the commit and
|
|
2988
|
+
// would send a short ref through to origin, which may grant. A directory
|
|
2989
|
+
// of that name is as malformed as a file that does not parse (the caller
|
|
2990
|
+
// answers null, and never falls through to a ref that might grant what
|
|
2991
|
+
// this one does not).
|
|
2992
|
+
const roles = fetched.get('roles.yaml') ?? { kind: 'missing' as const };
|
|
2993
|
+
if (roles.kind === 'missing') {
|
|
2994
|
+
if (listed.named.has('roles.yaml')) refuse('roles.yaml', 'is in the tree but its blob could not be read');
|
|
2995
|
+
return 'no-roles';
|
|
2996
|
+
}
|
|
2997
|
+
if (roles.kind === 'non-blob') return 'malformed';
|
|
2998
|
+
const rolesParsed = parseRolesYaml(roles.text);
|
|
2754
2999
|
if (!rolesParsed.ok) return 'malformed';
|
|
2755
3000
|
|
|
2756
3001
|
// Same group loading as the working-tree model, read AT THE COMMIT — the
|
|
@@ -2775,11 +3020,6 @@ export class AccessControlService implements IAccessControl {
|
|
|
2775
3020
|
for (const w of mergeWarnings) logger(`access@${tag}`).warn(w);
|
|
2776
3021
|
|
|
2777
3022
|
const accessFiles = new Map<string, AccessFile>();
|
|
2778
|
-
const listed = await this.listAccessFilesAtRef(repoDir, commit);
|
|
2779
|
-
if (listed === 'error') {
|
|
2780
|
-
logger(`access@${tag}`).warn('listing access.md files failed; refusing to decide from a partial tree');
|
|
2781
|
-
throw new AccessUnreadableError(label, 'access.md (ls-tree)');
|
|
2782
|
-
}
|
|
2783
3023
|
for (const p of listed.accessFiles) {
|
|
2784
3024
|
const text = await read(p);
|
|
2785
3025
|
if (text === null) continue;
|
|
@@ -2824,6 +3064,10 @@ export class AccessControlService implements IAccessControl {
|
|
|
2824
3064
|
deploymentOwners: this.deploymentOwners,
|
|
2825
3065
|
},
|
|
2826
3066
|
resolvedRef: commit,
|
|
3067
|
+
// The listing already named every path; held so an own-entries read
|
|
3068
|
+
// can tell absence from a lost blob, unless the tree is too large to
|
|
3069
|
+
// hold for every cached commit (then absence is simply not remembered).
|
|
3070
|
+
tree: listed.all.length <= this.treeIndexMaxPaths ? new Set(listed.all) : null,
|
|
2827
3071
|
};
|
|
2828
3072
|
}
|
|
2829
3073
|
}
|
|
@@ -128,6 +128,18 @@ export function createAccessRoutes(
|
|
|
128
128
|
return { roles, groups, plugins: plugins ?? [] };
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* The groups `email` may be offered as a grantee, or name in a grant: the
|
|
133
|
+
* resolver's ONE answer to who sees which group (`visibleGroups`: every
|
|
134
|
+
* group for an admin, only their own for anyone else), the same the
|
|
135
|
+
* Library's Groups tab lists. Read on the default branch, where the active
|
|
136
|
+
* group source lives. The picker never names a group the caller has no
|
|
137
|
+
* part in, and the grant route refuses one with the same words as a group
|
|
138
|
+
* that does not exist.
|
|
139
|
+
*/
|
|
140
|
+
const visibleGroups = (email: string, groups: string[]): Promise<string[]> =>
|
|
141
|
+
accessControl.visibleGroups(kb.defaultWorkspaceId(), email, groups);
|
|
142
|
+
|
|
131
143
|
/**
|
|
132
144
|
* The plugins `email` may DISCOVER — read the folder's access.md, the same
|
|
133
145
|
* verdict the plugin index lists on. Both the suggest list and the grant
|
|
@@ -491,8 +503,9 @@ export function createAccessRoutes(
|
|
|
491
503
|
* names — resolved from the DEFAULT branch, the authoritative source),
|
|
492
504
|
* groups (the ACTIVE group source, same authority), and people (branch-local
|
|
493
505
|
* to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
|
|
494
|
-
* file reads per keystroke. Roles
|
|
495
|
-
*
|
|
506
|
+
* file reads per keystroke. Roles are small + non-sensitive so they always
|
|
507
|
+
* show; GROUPS are the caller's own unless they are an admin (see
|
|
508
|
+
* `visibleGroups`); PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
|
|
496
509
|
* can't dump the whole directory (email-harvesting guard). People are the
|
|
497
510
|
* union of the KB-canonical set (roles.yaml + access.md grants) and the
|
|
498
511
|
* `users` table (logged-in users). Results are capped. Each person carries
|
|
@@ -527,7 +540,7 @@ export function createAccessRoutes(
|
|
|
527
540
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
528
541
|
.slice(0, CAP);
|
|
529
542
|
|
|
530
|
-
const matchedGroups = groups
|
|
543
|
+
const matchedGroups = (await visibleGroups(user.email, groups))
|
|
531
544
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
532
545
|
.slice(0, CAP);
|
|
533
546
|
|
|
@@ -934,9 +947,12 @@ export function createAccessRoutes(
|
|
|
934
947
|
} else if (principal.kind === 'group') {
|
|
935
948
|
// A group grant must name a group in the ACTIVE source (IdP-synced or
|
|
936
949
|
// manual) on the default branch — same authority the suggest list
|
|
937
|
-
// uses
|
|
938
|
-
//
|
|
939
|
-
|
|
950
|
+
// uses — and one the caller may see (`visibleGroups`): a group hidden
|
|
951
|
+
// from the picker is refused here with the same words as one that
|
|
952
|
+
// does not exist, so the grant route is no oracle for group names.
|
|
953
|
+
// No role-collision refusal: bare tokens resolve GROUP-FIRST, so the
|
|
954
|
+
// written bare name IS the group.
|
|
955
|
+
const groups = await visibleGroups(user.email, (await defaultBranchPrincipals()).groups);
|
|
940
956
|
const canonical = canonicalRoleName(principal.group);
|
|
941
957
|
if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
|
|
942
958
|
throw new AccessMutationError(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* They used to be appended, in full, to every tool description that they
|
|
5
5
|
* covered: the content rule rode on all twelve file tools, the agent-guide
|
|
6
|
-
* reminder on all of them
|
|
6
|
+
* reminder on all of them, the write-mode and escape-sequence
|
|
7
7
|
* paragraphs on two or three apiece. A description then ran to two or three
|
|
8
8
|
* thousand characters, most of it text the agent had already read on the tool
|
|
9
9
|
* above — and clients cut long descriptions from the END, which is where the
|