@bevel-software/platform-core-backend 0.12.0 → 0.13.1
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/THIRD-PARTY-NOTICES.md +9 -7
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +58 -2
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +174 -33
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
- package/dist/modules/access/admin-locked-commit.js +1 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.js +1 -1
- package/dist/modules/access/synced-groups-committer.js.map +1 -1
- package/dist/modules/access-model/access-errors.d.ts +11 -0
- package/dist/modules/access-model/access-errors.d.ts.map +1 -1
- package/dist/modules/access-model/access-errors.js +14 -0
- package/dist/modules/access-model/access-errors.js.map +1 -1
- package/dist/modules/access-model/access-grammar.d.ts +24 -8
- package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
- package/dist/modules/access-model/access-grammar.js +64 -3
- package/dist/modules/access-model/access-grammar.js.map +1 -1
- package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
- package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
- package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
- package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
- package/dist/modules/declared-variables/index.d.ts +2 -0
- package/dist/modules/declared-variables/index.d.ts.map +1 -0
- package/dist/modules/declared-variables/index.js +2 -0
- package/dist/modules/declared-variables/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts +1 -1
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -3
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/clone-config.d.ts +40 -2
- package/dist/modules/kb-fs/clone-config.d.ts.map +1 -1
- package/dist/modules/kb-fs/clone-config.js +94 -2
- package/dist/modules/kb-fs/clone-config.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +20 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/kb-fs/repo-path.d.ts +32 -0
- package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
- package/dist/modules/kb-fs/repo-path.js +54 -0
- package/dist/modules/kb-fs/repo-path.js.map +1 -0
- package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
- package/dist/modules/secrets-vault/mcp-oauth-discovery.service.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 +20 -2
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
- package/dist/modules/tool-helpers/tool-context.js +1 -0
- package/dist/modules/tool-helpers/tool-context.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
- package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +32 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +70 -5
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +20 -2
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +74 -16
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +12 -0
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +39 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +116 -6
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-git.js +21 -4
- package/dist/modules/workspace/startup/kb-git.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +52 -8
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +121 -23
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +31 -15
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +52 -8
- package/package.json +6 -5
- package/src/core/create-core-server.ts +13 -1
- package/src/core/create-core-services.ts +1 -0
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
- package/src/modules/access/__tests__/access-groups.test.ts +28 -0
- package/src/modules/access/access-control.service.ts +198 -37
- package/src/modules/access/admin-locked-commit.ts +1 -0
- package/src/modules/access/synced-groups-committer.ts +1 -1
- package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
- package/src/modules/access-model/access-errors.ts +19 -0
- package/src/modules/access-model/access-grammar.ts +67 -3
- package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
- package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
- package/src/modules/declared-variables/index.ts +1 -0
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
- package/src/modules/diff/diff.routes.ts +3 -2
- package/src/modules/kb-fs/__tests__/clone-config.test.ts +63 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- package/src/modules/kb-fs/clone-config.ts +97 -2
- package/src/modules/kb-fs/locking-filesystem.ts +30 -0
- package/src/modules/kb-fs/repo-path.ts +56 -0
- package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
- package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
- package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
- package/src/modules/secrets-vault/secrets-vault.routes.ts +20 -2
- package/src/modules/tool-helpers/tool-context.ts +1 -0
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
- package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
- package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
- package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
- package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
- package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
- package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +11 -5
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +172 -7
- package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
- package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
- package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
- package/src/modules/workflow/git/git.service.ts +73 -6
- package/src/modules/workflow/git/pull-request.service.ts +22 -4
- package/src/modules/workflow/pending-commits.worker.ts +80 -18
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
- package/src/modules/workflow/workflow.routes.ts +12 -0
- package/src/modules/workflow/workflow.service.ts +123 -7
- package/src/modules/workspace/__tests__/workspace.service.test.ts +1 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +141 -0
- package/src/modules/workspace/startup/kb-git.ts +20 -7
- package/src/modules/workspace/workspace.service.ts +132 -25
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { assertInsideRepo, isInsideRepo } from '../repo-path.js';
|
|
3
|
+
import { WorkflowValidationError } from '../../../shared/domain-errors.js';
|
|
4
|
+
|
|
5
|
+
const KB = 'knowledge-base';
|
|
6
|
+
|
|
7
|
+
describe('isInsideRepo', () => {
|
|
8
|
+
it('accepts the repository folder and anything under it', () => {
|
|
9
|
+
expect(isInsideRepo('knowledge-base', KB)).toBe(true);
|
|
10
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/Foo.md', KB)).toBe(true);
|
|
11
|
+
expect(isInsideRepo('knowledge-base/roles.yaml', KB)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('refuses a repo-relative path: it would land beside the repository, where git never looks', () => {
|
|
15
|
+
expect(isInsideRepo('KnowledgeBase/Reviews/PR-12.html', KB)).toBe(false);
|
|
16
|
+
expect(isInsideRepo('review.log', KB)).toBe(false);
|
|
17
|
+
expect(isInsideRepo('tmp/spill.txt', KB)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('matches the folder as a whole segment, not as a string prefix', () => {
|
|
21
|
+
expect(isInsideRepo('knowledge-based/x.md', KB)).toBe(false);
|
|
22
|
+
expect(isInsideRepo('knowledge-base-old/x.md', KB)).toBe(false);
|
|
23
|
+
expect(isInsideRepo('knowledge-base.bak', KB)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('refuses traversal that starts under the prefix and climbs back out', () => {
|
|
27
|
+
// `knowledge-base/../stray.md` starts with the clone folder but resolves
|
|
28
|
+
// beside it: the containment check is against the WORKSPACE dir, so the
|
|
29
|
+
// filesystem would accept it and the bytes would land outside git.
|
|
30
|
+
expect(isInsideRepo('knowledge-base/../stray.md', KB)).toBe(false);
|
|
31
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/../../stray.md', KB)).toBe(false);
|
|
32
|
+
expect(isInsideRepo('knowledge-base/./x.md', KB)).toBe(false);
|
|
33
|
+
expect(isInsideRepo('knowledge-base//x.md', KB)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('refuses backslashes anywhere: on Windows they separate segments too, and the commit layer rejects them', () => {
|
|
37
|
+
expect(isInsideRepo('knowledge-base/foo\\..\\..\\outside.md', KB)).toBe(false);
|
|
38
|
+
expect(isInsideRepo('knowledge-base\\KnowledgeBase\\x.md', KB)).toBe(false);
|
|
39
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/a\\b.md', KB)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('tolerates a trailing slash on a directory path', () => {
|
|
43
|
+
expect(isInsideRepo('knowledge-base/', KB)).toBe(true);
|
|
44
|
+
expect(isInsideRepo('knowledge-base/KnowledgeBase/Projects/', KB)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('refuses dressed-up forms of the prefix that the commit layer would reject anyway', () => {
|
|
48
|
+
expect(isInsideRepo('./knowledge-base/x.md', KB)).toBe(false);
|
|
49
|
+
expect(isInsideRepo('/knowledge-base/x.md', KB)).toBe(false);
|
|
50
|
+
expect(isInsideRepo('', KB)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('assertInsideRepo', () => {
|
|
55
|
+
it('passes silently for a path inside the repository', () => {
|
|
56
|
+
expect(() => assertInsideRepo('knowledge-base/KnowledgeBase/Foo.md', KB)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws a 400 that names the prefix and spells out the corrected path', () => {
|
|
60
|
+
let err: unknown;
|
|
61
|
+
try {
|
|
62
|
+
assertInsideRepo('KnowledgeBase/Reviews/PR-12.html', KB);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
err = e;
|
|
65
|
+
}
|
|
66
|
+
expect(err).toBeInstanceOf(WorkflowValidationError);
|
|
67
|
+
const e = err as WorkflowValidationError;
|
|
68
|
+
expect(e.status).toBe(400);
|
|
69
|
+
// The offending path, the rule, and the exact path to use instead: an
|
|
70
|
+
// agent reading this error can retry without guessing.
|
|
71
|
+
expect(e.message).toContain('"KnowledgeBase/Reviews/PR-12.html"');
|
|
72
|
+
expect(e.message).toContain('"knowledge-base/"');
|
|
73
|
+
expect(e.message).toContain('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
74
|
+
expect(e.payload).toMatchObject({
|
|
75
|
+
kind: 'path-outside-repo',
|
|
76
|
+
path: 'KnowledgeBase/Reviews/PR-12.html',
|
|
77
|
+
kbDirName: KB,
|
|
78
|
+
// On its own as well: the pending-commits worker's notice reads it from
|
|
79
|
+
// here, because the message it keeps is truncated.
|
|
80
|
+
corrected: 'knowledge-base/KnowledgeBase/Reviews/PR-12.html',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('suggests the path with the traversal collapsed, back under the prefix', () => {
|
|
85
|
+
expect(() => assertInsideRepo('knowledge-base/../stray.md', KB)).toThrow('"knowledge-base/stray.md"');
|
|
86
|
+
expect(() => assertInsideRepo('knowledge-base/../../escape.md', KB)).toThrow('"knowledge-base/escape.md"');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('suggests a forward-slash path for a backslash one', () => {
|
|
90
|
+
expect(() => assertInsideRepo('knowledge-base/foo\\..\\..\\outside.md', KB)).toThrow('"knowledge-base/outside.md"');
|
|
91
|
+
expect(() => assertInsideRepo('KnowledgeBase\\Reviews\\PR-12.html', KB)).toThrow('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('never suggests a path that still climbs: a bare `..` corrects to the repository folder', () => {
|
|
95
|
+
for (const p of ['..', '../', '../..', '../../', 'knowledge-base/..']) {
|
|
96
|
+
let message = '';
|
|
97
|
+
try {
|
|
98
|
+
assertInsideRepo(p, KB);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
message = (e as Error).message;
|
|
101
|
+
}
|
|
102
|
+
expect(message, p).toContain('Use "knowledge-base/" instead');
|
|
103
|
+
expect(message, p).not.toMatch(/Use "[^"]*\.\.[^"]*" instead/);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The git configuration a per-branch workspace clone must
|
|
3
|
-
* to
|
|
2
|
+
* The git configuration a per-branch workspace clone must carry: the tracking
|
|
3
|
+
* config that pins its branch to exactly ONE upstream ref, and the credential
|
|
4
|
+
* helper its remote operations authenticate with.
|
|
5
|
+
*
|
|
6
|
+
* Both are stamped at clone time AND re-stamped when an existing clone is
|
|
7
|
+
* adopted from disk, so a clone created by an older build — or by a path that
|
|
8
|
+
* only authenticated its own invocation — is repaired rather than left broken.
|
|
9
|
+
*
|
|
10
|
+
* On the tracking half:
|
|
4
11
|
*
|
|
5
12
|
* Why this exists: a clone's config is append-only from git's point of view,
|
|
6
13
|
* and several things write to it:
|
|
@@ -69,3 +76,91 @@ export function cloneTrackingConfigArgs(branch: string): string[][] {
|
|
|
69
76
|
['config', '--replace-all', `branch.${branch}.merge`, `refs/heads/${branch}`],
|
|
70
77
|
];
|
|
71
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The inline credential helper a workspace clone authenticates its remote
|
|
82
|
+
* operations with, or null when the deployment has no git token configured.
|
|
83
|
+
*
|
|
84
|
+
* The snippet reads `$GITHUB_TOKEN` at CALL time, so the secret itself never
|
|
85
|
+
* enters argv, the clone's `.git/config`, or any error message quoting either.
|
|
86
|
+
* `GITHUB_TOKEN` is the normalised name — `CoreConfig` collapses `GIT_TOKEN` /
|
|
87
|
+
* `GH_TOKEN` onto it at boot, and `DeploymentSettingsService.syncGitTokenEnv`
|
|
88
|
+
* publishes a token supplied through the setup screen to the same place — so
|
|
89
|
+
* this one name covers every way a token can arrive.
|
|
90
|
+
*
|
|
91
|
+
* The username is provider-specific (GitHub `x-access-token`, GitLab `oauth2`,
|
|
92
|
+
* Bitbucket `x-token-auth`); the token is always the Basic-auth password,
|
|
93
|
+
* which every major host accepts.
|
|
94
|
+
*/
|
|
95
|
+
export function credentialHelperValue(gitUsername: string): string | null {
|
|
96
|
+
if (!process.env.GITHUB_TOKEN) return null;
|
|
97
|
+
// Interpolated into a shell snippet that git will execute. `CoreConfig` and
|
|
98
|
+
// `DeploymentSettingsService` both reject a username outside this charset
|
|
99
|
+
// before it can get this far, so reaching the throw means an unvalidated
|
|
100
|
+
// fourth path appeared — fail closed rather than emit an injectable helper.
|
|
101
|
+
if (!/^[A-Za-z0-9._-]+$/.test(gitUsername)) {
|
|
102
|
+
throw new Error(`git username must match [A-Za-z0-9._-]+; got "${gitUsername}"`);
|
|
103
|
+
}
|
|
104
|
+
// printf '%s' rather than echo: POSIX leaves echo's handling of backslash
|
|
105
|
+
// escapes implementation-defined (dash interprets \c as "stop output"), so
|
|
106
|
+
// a token containing a backslash sequence could be silently truncated on
|
|
107
|
+
// the way to git. printf %s passes the bytes through verbatim. The literal
|
|
108
|
+
// `password=$GITHUB_TOKEN` stays in the snippet, which is what
|
|
109
|
+
// APP_HELPER_VALUE_PATTERN keys on — echo-era helpers stamped by earlier
|
|
110
|
+
// builds still match it and get replaced on the next stamp.
|
|
111
|
+
return `!f() { printf '%s\\n' "username=${gitUsername}" "password=$GITHUB_TOKEN"; }; f`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `git clone` arguments that PERSIST the credential helper into the repository
|
|
116
|
+
* being created. Position is load-bearing: these belong AFTER the `clone`
|
|
117
|
+
* subcommand, where `--config` means "write this into the new repo's config".
|
|
118
|
+
* The same pair placed BEFORE it is git's per-invocation `-c`, which
|
|
119
|
+
* authenticates that one clone and leaves the resulting repo with no helper at
|
|
120
|
+
* all — so every later `git push` from that clone prompts for a username,
|
|
121
|
+
* finds no tty, and dies. That was a real outage: a self-hosted deployment
|
|
122
|
+
* cloned fine, committed fine, and silently pushed nothing for two days.
|
|
123
|
+
*
|
|
124
|
+
* Empty when no token is configured, matching the un-authenticated clone the
|
|
125
|
+
* rest of the layer already supports.
|
|
126
|
+
*/
|
|
127
|
+
export function cloneCredentialArgs(gitUsername: string): string[] {
|
|
128
|
+
const helper = credentialHelperValue(gitUsername);
|
|
129
|
+
return helper ? ['--config', `credential.helper=${helper}`] : [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `git` argument lists that bring a clone's credential helper into line with
|
|
134
|
+
* the deployment's current config — the repair path for clones on disk (see
|
|
135
|
+
* `cloneCredentialArgs`), applied wherever `cloneTrackingConfigArgs` is:
|
|
136
|
+
* `--replace-all` when a token is configured (idempotent re-stamp, stale
|
|
137
|
+
* values collapse), `--unset-all` when the deployment lost its token (a
|
|
138
|
+
* leftover helper answering with an empty password must not shadow whatever
|
|
139
|
+
* auth the operator fell back to; unset of a missing value exits non-zero
|
|
140
|
+
* and callers tolerate it — see `stampCredentialHelper`). Both operations
|
|
141
|
+
* are scoped by the value pattern below so only app-owned helpers are
|
|
142
|
+
* touched. Returns the arguments rather than running them, for the same
|
|
143
|
+
* reason `cloneTrackingConfigArgs` does.
|
|
144
|
+
*/
|
|
145
|
+
/**
|
|
146
|
+
* Value pattern (a POSIX ERE, as `git config`'s optional value-pattern
|
|
147
|
+
* argument expects) that matches ONLY helpers this application stamped —
|
|
148
|
+
* every one of ours reads `password=$GITHUB_TOKEN`, and no operator-authored
|
|
149
|
+
* helper has a reason to contain that literal. Scoping both the replace and
|
|
150
|
+
* the unset to it means an operator's own clone-local helper (`store`,
|
|
151
|
+
* `cache`, something custom) is never collapsed away or deleted by our
|
|
152
|
+
* stamping: git chains all configured helpers in order, so ours and theirs
|
|
153
|
+
* coexist.
|
|
154
|
+
*/
|
|
155
|
+
const APP_HELPER_VALUE_PATTERN = 'password=\\$GITHUB_TOKEN';
|
|
156
|
+
|
|
157
|
+
export function cloneCredentialConfigArgs(gitUsername: string): string[][] {
|
|
158
|
+
const helper = credentialHelperValue(gitUsername);
|
|
159
|
+
// `--replace-all key value pattern` replaces every line MATCHING the
|
|
160
|
+
// pattern with the one value (adding it when none match) and leaves
|
|
161
|
+
// non-matching values — operator helpers — untouched. Same scoping on the
|
|
162
|
+
// unset: only app-stamped values are removed when the token goes away.
|
|
163
|
+
return helper
|
|
164
|
+
? [['config', '--replace-all', 'credential.helper', helper, APP_HELPER_VALUE_PATTERN]]
|
|
165
|
+
: [['config', '--unset-all', 'credential.helper', APP_HELPER_VALUE_PATTERN]];
|
|
166
|
+
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
import type { AuthUser, Change, IWorkflowService } from '@bevel-software/platform-shared';
|
|
45
45
|
import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
|
|
46
46
|
import type { FileChangeNotifier } from './file-change-notifier.js';
|
|
47
|
+
import { assertInsideRepo } from './repo-path.js';
|
|
47
48
|
import type { CreationGrantPlan, ICreatorAccess } from '../access-model/creator.js';
|
|
48
49
|
|
|
49
50
|
/** How many times to retry a contended acquire before giving up. */
|
|
@@ -62,6 +63,16 @@ export interface LockingFilesystemContext {
|
|
|
62
63
|
workspaceId: string;
|
|
63
64
|
branch: string;
|
|
64
65
|
user: AuthUser;
|
|
66
|
+
/**
|
|
67
|
+
* The clone folder at the workspace root (`knowledge-base`). The filesystem
|
|
68
|
+
* is rooted one level ABOVE the repository, so containment alone lets a
|
|
69
|
+
* repo-relative path (`KnowledgeBase/…`) land beside the clone, where git
|
|
70
|
+
* never sees it and the release commit quietly no-ops. Every op that
|
|
71
|
+
* creates or modifies bytes is refused unless its path starts with this
|
|
72
|
+
* folder; removals stay open so a stray the old behaviour left there can
|
|
73
|
+
* still be cleaned up.
|
|
74
|
+
*/
|
|
75
|
+
kbDirName: string;
|
|
65
76
|
/**
|
|
66
77
|
* Optional pre-disk write validator. Invoked with the (workspace-relative
|
|
67
78
|
* path, full content) of every WHOLE-FILE write BEFORE the bytes land. Throw
|
|
@@ -104,6 +115,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
104
115
|
content: FileContent,
|
|
105
116
|
options?: WriteOptions,
|
|
106
117
|
): Promise<void> {
|
|
118
|
+
this.assertInsideRepo(inputPath);
|
|
107
119
|
// Pre-disk gate (e.g. reject a roles.yaml edit that would lock out admins).
|
|
108
120
|
// Runs OUTSIDE the lock so a refusal never acquires/holds one.
|
|
109
121
|
this.lockContext.validateWrite?.(inputPath, content);
|
|
@@ -121,6 +133,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
override async appendFile(inputPath: string, content: FileContent): Promise<void> {
|
|
136
|
+
this.assertInsideRepo(inputPath);
|
|
124
137
|
return this.withLock(inputPath, () => super.appendFile(inputPath, content));
|
|
125
138
|
}
|
|
126
139
|
|
|
@@ -129,12 +142,16 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
override async copyFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
145
|
+
this.assertInsideRepo(dest);
|
|
132
146
|
// Lock on `dest`. `src` is read-only from this op's perspective — copy
|
|
133
147
|
// creates a new file at dest without disturbing src on disk.
|
|
134
148
|
return this.withLock(dest, () => super.copyFile(src, dest, options));
|
|
135
149
|
}
|
|
136
150
|
|
|
137
151
|
override async moveFile(src: string, dest: string, options?: CopyOptions): Promise<void> {
|
|
152
|
+
// Only the destination is gated: moving a stray INTO the repository is how
|
|
153
|
+
// a file the old behaviour left beside the clone gets rescued.
|
|
154
|
+
this.assertInsideRepo(dest);
|
|
138
155
|
// A move mutates two paths: the source is deleted and the destination
|
|
139
156
|
// is created. Only locking `dest` would let a concurrent writer hold
|
|
140
157
|
// the source's lock and mutate it under us, and would also leave the
|
|
@@ -183,6 +200,9 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
183
200
|
deletes: string[] = [],
|
|
184
201
|
): Promise<Change | null> {
|
|
185
202
|
const { workflow, workspaceId, branch, user } = this.lockContext;
|
|
203
|
+
// Fail the whole batch before any plan, validator or lock: one stray path
|
|
204
|
+
// must not let its siblings land while it silently misses git.
|
|
205
|
+
for (const w of writes) this.assertInsideRepo(w.path);
|
|
186
206
|
// Creator read grants for the batch: transform new markdown files'
|
|
187
207
|
// content in place, and fold any subtree access.md seeds into the SAME
|
|
188
208
|
// atomic batch (deduped — several files landing in one new folder share
|
|
@@ -479,6 +499,7 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
479
499
|
}
|
|
480
500
|
|
|
481
501
|
override async mkdir(inputPath: string, options?: { recursive?: boolean }): Promise<void> {
|
|
502
|
+
this.assertInsideRepo(inputPath);
|
|
482
503
|
// Creator read grant: planned BEFORE the dir exists (the plan's
|
|
483
504
|
// new-directory detection needs the pre-creation tree), seeded right
|
|
484
505
|
// after — so the new folder's access.md names the acting user under
|
|
@@ -527,6 +548,15 @@ export class LockingFilesystem extends LocalFilesystem {
|
|
|
527
548
|
);
|
|
528
549
|
}
|
|
529
550
|
|
|
551
|
+
/**
|
|
552
|
+
* Refuse a path that would put bytes beside the repository instead of in
|
|
553
|
+
* it. Runs before any side effect (plan, validator, lock, disk) so a refusal
|
|
554
|
+
* leaves nothing behind. See `LockingFilesystemContext.kbDirName`.
|
|
555
|
+
*/
|
|
556
|
+
private assertInsideRepo(inputPath: string): void {
|
|
557
|
+
assertInsideRepo(inputPath, this.lockContext.kbDirName);
|
|
558
|
+
}
|
|
559
|
+
|
|
530
560
|
/**
|
|
531
561
|
* Land a `seed-access-md` creation-grant plan: under the access.md's own
|
|
532
562
|
* lock (single acquire+release cycle → one commit), re-read the CURRENT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { WorkflowValidationError } from '../../shared/domain-errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Workspace-relative paths and the repository folder.
|
|
6
|
+
*
|
|
7
|
+
* A workspace directory holds the git clone as `<kbDirName>/` (plus transient
|
|
8
|
+
* scratch such as `tmp/`). The agent filesystem, the lock keys and the human
|
|
9
|
+
* editor all speak WORKSPACE-relative paths, so a path reaches git only when
|
|
10
|
+
* it starts with that folder. `KnowledgeBase/Foo.md` on its own is not a
|
|
11
|
+
* shorthand for `knowledge-base/KnowledgeBase/Foo.md`: it is a different
|
|
12
|
+
* location, beside the clone, that nothing commits and nothing pushes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True when `wsPath` is the repository folder or lies under it.
|
|
17
|
+
*
|
|
18
|
+
* Judged segment by segment, not by string prefix: `knowledge-base/../x.md`
|
|
19
|
+
* starts with the folder yet resolves beside it, and the filesystem's own
|
|
20
|
+
* containment is against the WORKSPACE dir, so it would accept that path.
|
|
21
|
+
* `.` and `..` segments (and empty ones from `//`) are therefore refused
|
|
22
|
+
* outright, and so is any backslash: on Windows it separates segments too,
|
|
23
|
+
* so `foo\..\..` would climb the same way. Both match what the commit layer
|
|
24
|
+
* accepts. A single trailing slash on a directory path is tolerated.
|
|
25
|
+
*/
|
|
26
|
+
export function isInsideRepo(wsPath: string, kbDirName: string): boolean {
|
|
27
|
+
if (typeof wsPath !== 'string' || wsPath.includes('\\')) return false;
|
|
28
|
+
const segments = wsPath.replace(/\/$/, '').split('/');
|
|
29
|
+
if (segments[0] !== kbDirName) return false;
|
|
30
|
+
return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Refuse a workspace-relative path that would land outside the repository.
|
|
35
|
+
* The message carries the corrected path so an agent can retry without
|
|
36
|
+
* guessing; the payload carries a typed discriminator for callers that
|
|
37
|
+
* switch on it, plus the same corrected path on its own, for a consumer
|
|
38
|
+
* that cannot rely on the message surviving intact (the pending-commits
|
|
39
|
+
* worker stores a sanitized, truncated copy).
|
|
40
|
+
*/
|
|
41
|
+
export function assertInsideRepo(wsPath: string, kbDirName: string): void {
|
|
42
|
+
if (isInsideRepo(wsPath, kbDirName)) return;
|
|
43
|
+
// The suggestion collapses what made the path wrong: backslashes, leading
|
|
44
|
+
// `./` or `/`, and any `..` climbing back out (a bare `..` included, so the
|
|
45
|
+
// suggestion is never itself a climb). The agent gets a path it can use.
|
|
46
|
+
const normalized = path.posix
|
|
47
|
+
.normalize(String(wsPath).replace(/\\/g, '/').replace(/^(\.\/|\/)+/, ''))
|
|
48
|
+
.replace(/^(\.\.(\/|$))+/, '')
|
|
49
|
+
.replace(/^\.$/, '');
|
|
50
|
+
const corrected = isInsideRepo(normalized, kbDirName) ? normalized : `${kbDirName}/${normalized}`;
|
|
51
|
+
throw new WorkflowValidationError(
|
|
52
|
+
`"${wsPath}" is outside the knowledge base repository: paths are workspace-relative, must start with "${kbDirName}/" and may not contain "." or ".." segments or backslashes. ` +
|
|
53
|
+
`Use "${corrected}" instead. A file written without that prefix lands beside the repository, where it is never committed or pushed.`,
|
|
54
|
+
{ kind: 'path-outside-repo', path: wsPath, kbDirName, corrected },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -151,6 +151,96 @@ describe('DbSecretsVaultService — PKCE + public-client tool OAuth', () => {
|
|
|
151
151
|
expect(stored.tokens.access_token).toBe('at-1');
|
|
152
152
|
expect(stored.pendingVerifier).toBeUndefined();
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('beginToolOAuthByKey remembers the scopes it asked for, and completeOAuth takes them as granted when the provider echoes none', async () => {
|
|
156
|
+
// RFC 6749 §5.1: `scope` in the token response is OPTIONAL when identical
|
|
157
|
+
// to what was requested. A provider that omits it (HubSpot) granted the
|
|
158
|
+
// request, not nothing — reading it as nothing would flag every declared
|
|
159
|
+
// scope as missing and block the tool behind a permanent "sign in again".
|
|
160
|
+
const begin = makeFakeDb([[sharedRow()], [], [{ id: 'user-row-1' }]]);
|
|
161
|
+
const svc = new DbSecretsVaultService(begin.db, ENC_KEY);
|
|
162
|
+
const { url } = await svc.beginToolOAuthByKey({
|
|
163
|
+
userId: 'user-1',
|
|
164
|
+
key: KEY,
|
|
165
|
+
redirectUri: 'https://bevel.example.com/cb',
|
|
166
|
+
state: 's',
|
|
167
|
+
scopes: ['crm.objects.contacts.read', 'crm.objects.companies.read'],
|
|
168
|
+
});
|
|
169
|
+
expect(new URL(url).searchParams.get('scope')).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
170
|
+
const pending = JSON.parse(crypto.decrypt(begin.captured.values[0].valueEncrypted));
|
|
171
|
+
expect(pending.pendingScopes).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
172
|
+
|
|
173
|
+
const complete = async (tokenResponse: Record<string, unknown>) => {
|
|
174
|
+
const userRow = sharedRow({
|
|
175
|
+
id: 'user-row-1',
|
|
176
|
+
userId: 'user-1',
|
|
177
|
+
valueEncrypted: crypto.encrypt(
|
|
178
|
+
JSON.stringify({ pendingVerifier: 'v', pendingScopes: 'crm.objects.contacts.read crm.objects.companies.read' }),
|
|
179
|
+
),
|
|
180
|
+
});
|
|
181
|
+
const { db, captured } = makeFakeDb([[userRow], undefined]);
|
|
182
|
+
vi.stubGlobal(
|
|
183
|
+
'fetch',
|
|
184
|
+
vi.fn(async () => new Response(JSON.stringify(tokenResponse), { status: 200, headers: { 'Content-Type': 'application/json' } })),
|
|
185
|
+
);
|
|
186
|
+
await new DbSecretsVaultService(db, ENC_KEY).completeOAuth('user-1', 'user-row-1', 'code', 'https://bevel.example.com/cb');
|
|
187
|
+
return JSON.parse(crypto.decrypt(captured.set[0].valueEncrypted));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// No `scope` echoed → the requested scopes are the granted ones.
|
|
191
|
+
const silent = await complete({ access_token: 'at-1', expires_in: 3600 });
|
|
192
|
+
expect(silent.tokens.scope).toBe('crm.objects.contacts.read crm.objects.companies.read');
|
|
193
|
+
expect(silent.pendingScopes).toBeUndefined(); // one-time, like the verifier
|
|
194
|
+
// An echoed `scope` is the provider's word and wins — a narrower grant stays visible.
|
|
195
|
+
const echoed = await complete({ access_token: 'at-2', expires_in: 3600, scope: 'crm.objects.contacts.read' });
|
|
196
|
+
expect(echoed.tokens.scope).toBe('crm.objects.contacts.read');
|
|
197
|
+
// …and so does an EXPLICIT empty grant: only an absent field means "as requested".
|
|
198
|
+
const empty = await complete({ access_token: 'at-3', expires_in: 3600, scope: '' });
|
|
199
|
+
expect(empty.tokens.scope).toBe('');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('beginOAuth never writes a stale blob over tokens a concurrent refresh just rotated', async () => {
|
|
203
|
+
// The standalone flow stashes the pending verifier/scopes with a
|
|
204
|
+
// read-modify-write. Between its read and its write a refresh persists
|
|
205
|
+
// rotated tokens; the guarded update misses (0 rows), the row is re-read,
|
|
206
|
+
// and the pending fields are merged onto THOSE tokens.
|
|
207
|
+
const stale = sharedRow({
|
|
208
|
+
id: 'secret-1',
|
|
209
|
+
userId: 'user-1',
|
|
210
|
+
oauthMeta: { ...PUBLIC_META, pkce: false },
|
|
211
|
+
valueEncrypted: crypto.encrypt(JSON.stringify({ tokens: { access_token: 'old', refresh_token: 'rt-old' } })),
|
|
212
|
+
});
|
|
213
|
+
const rotated = {
|
|
214
|
+
...stale,
|
|
215
|
+
valueEncrypted: crypto.encrypt(JSON.stringify({ tokens: { access_token: 'new', refresh_token: 'rt-new' } })),
|
|
216
|
+
};
|
|
217
|
+
const { db, captured } = makeFakeDb([
|
|
218
|
+
[stale], // requireRow
|
|
219
|
+
[], // guarded update: the ciphertext changed underneath us
|
|
220
|
+
[rotated], // re-read
|
|
221
|
+
[{ id: 'secret-1' }], // guarded update against the fresh ciphertext
|
|
222
|
+
]);
|
|
223
|
+
const url = await new DbSecretsVaultService(db, ENC_KEY).beginOAuth('user-1', 'secret-1', 'https://bevel.example.com/cb', 's');
|
|
224
|
+
expect(new URL(url).searchParams.get('scope')).toBe('mcp.read');
|
|
225
|
+
expect(captured.set).toHaveLength(2);
|
|
226
|
+
const persisted = JSON.parse(crypto.decrypt(captured.set[1].valueEncrypted));
|
|
227
|
+
expect(persisted.tokens).toEqual({ access_token: 'new', refresh_token: 'rt-new' });
|
|
228
|
+
expect(persisted.pendingScopes).toBe('mcp.read');
|
|
229
|
+
|
|
230
|
+
// Only a token rotation is merge-able: a row that meanwhile became another
|
|
231
|
+
// provider's sign-in (or a static value) aborts, since the consent URL was
|
|
232
|
+
// built for the provider we read first.
|
|
233
|
+
for (const changed of [
|
|
234
|
+
{ ...rotated, oauthMeta: { ...PUBLIC_META, pkce: false, clientId: 'someone-else' } },
|
|
235
|
+
{ ...rotated, kind: 'static' },
|
|
236
|
+
]) {
|
|
237
|
+
const race = makeFakeDb([[stale], [], [changed]]);
|
|
238
|
+
await expect(
|
|
239
|
+
new DbSecretsVaultService(race.db, ENC_KEY).beginOAuth('user-1', 'secret-1', 'https://bevel.example.com/cb', 's'),
|
|
240
|
+
).rejects.toBeInstanceOf(SecretOAuthError);
|
|
241
|
+
expect(race.captured.set).toHaveLength(1); // nothing written onto the changed row
|
|
242
|
+
}
|
|
243
|
+
});
|
|
154
244
|
});
|
|
155
245
|
|
|
156
246
|
describe('DbSecretsVaultService — dead-grant detection on refresh', () => {
|
|
@@ -210,6 +300,20 @@ describe('DbSecretsVaultService — dead-grant detection on refresh', () => {
|
|
|
210
300
|
expect(onMutation).toHaveBeenCalledWith('user-1');
|
|
211
301
|
});
|
|
212
302
|
|
|
303
|
+
it('a refresh keeps the recorded grant when `scope` is absent, and takes an echoed one — empty included', async () => {
|
|
304
|
+
// Same rule as the code exchange: only an ABSENT field means "unchanged".
|
|
305
|
+
const refreshWith = async (body: Record<string, unknown>) => {
|
|
306
|
+
const { db, captured } = makeFakeDb([[userRow()], undefined]);
|
|
307
|
+
const svc = new DbSecretsVaultService(db, ENC_KEY, undefined, userScope);
|
|
308
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 })));
|
|
309
|
+
await svc.resolve('user-1', KEY);
|
|
310
|
+
return JSON.parse(crypto.decrypt(captured.set[0].valueEncrypted)).tokens.scope;
|
|
311
|
+
};
|
|
312
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600 })).toBe('mcp.read');
|
|
313
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600, scope: 'mcp.read mcp.write' })).toBe('mcp.read mcp.write');
|
|
314
|
+
expect(await refreshWith({ access_token: 'a', expires_in: 3600, scope: '' })).toBe('');
|
|
315
|
+
});
|
|
316
|
+
|
|
213
317
|
it('putSharedOAuthProvider notifies mutation listeners with null (shared → everyone)', async () => {
|
|
214
318
|
const { db } = makeFakeDb([undefined]); // provider upsert
|
|
215
319
|
const svc = new DbSecretsVaultService(db, ENC_KEY);
|
|
@@ -147,10 +147,62 @@ describe('McpOAuthDiscoveryService', () => {
|
|
|
147
147
|
});
|
|
148
148
|
const res = await makeService(vault, fetchFn).statusFor('notion', MCP_URL);
|
|
149
149
|
expect(res.status).toBe('unsupported');
|
|
150
|
+
// The reason is the admin's to-do list: it names the redirect URI to
|
|
151
|
+
// register and where the client id goes, because that is the next step.
|
|
152
|
+
expect(res).toMatchObject({ reason: expect.stringContaining(REDIRECT_URI) });
|
|
153
|
+
expect(res).toMatchObject({ reason: expect.stringContaining('client id') });
|
|
150
154
|
expect(vault.putSharedOAuthProvider).not.toHaveBeenCalled();
|
|
151
155
|
warn.mockRestore();
|
|
152
156
|
});
|
|
153
157
|
|
|
158
|
+
it('completes an owner-declared client from the server metadata — no registration, nothing persisted', async () => {
|
|
159
|
+
const vault = makeVault();
|
|
160
|
+
const { fetchFn, calls } = makeFetch({
|
|
161
|
+
[MCP_URL]: () => json({}, 401),
|
|
162
|
+
[PRM_URL]: () => json(PRM),
|
|
163
|
+
[AS_META_URL]: () => json({ ...AS_META, registration_endpoint: undefined }),
|
|
164
|
+
});
|
|
165
|
+
const svc = makeService(vault, fetchFn);
|
|
166
|
+
const res = await svc.providerForDeclaredClient('hubspot', MCP_URL, 'owner-app-1');
|
|
167
|
+
expect(res).toEqual({
|
|
168
|
+
status: 'oauth',
|
|
169
|
+
provider: {
|
|
170
|
+
authorizationUrl: 'https://auth.example.com/authorize',
|
|
171
|
+
tokenUrl: 'https://auth.example.com/token',
|
|
172
|
+
clientId: 'owner-app-1',
|
|
173
|
+
scopes: ['mcp.read'],
|
|
174
|
+
pkce: true,
|
|
175
|
+
resource: MCP_URL,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
expect(calls).not.toContain('https://auth.example.com/register');
|
|
179
|
+
expect(vault.putSharedOAuthProvider).not.toHaveBeenCalled();
|
|
180
|
+
// The metadata walk is per server, so a second declared client on the
|
|
181
|
+
// same URL costs no network — and gets ITS client id, not the first one's.
|
|
182
|
+
const before = calls.length;
|
|
183
|
+
const again = await svc.providerForDeclaredClient('hubspot_eu', MCP_URL, 'owner-app-2');
|
|
184
|
+
expect(again).toMatchObject({ status: 'oauth', provider: { clientId: 'owner-app-2' } });
|
|
185
|
+
expect(calls.length).toBe(before);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('refuses to complete a declared client when the server is open or publishes no metadata', async () => {
|
|
189
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
190
|
+
const open = makeService(makeVault(), makeFetch({ [MCP_URL]: () => json({ ok: true }) }).fetchFn);
|
|
191
|
+
expect(await open.providerForDeclaredClient('x', MCP_URL, 'c')).toMatchObject({
|
|
192
|
+
status: 'unsupported',
|
|
193
|
+
reason: expect.stringContaining('authorizationUrl'),
|
|
194
|
+
});
|
|
195
|
+
const noMeta = makeService(
|
|
196
|
+
makeVault(),
|
|
197
|
+
makeFetch({ [MCP_URL]: () => json({}, 401), [PRM_URL]: () => json(PRM) }).fetchFn,
|
|
198
|
+
);
|
|
199
|
+
expect(await noMeta.providerForDeclaredClient('x', MCP_URL, 'c')).toMatchObject({
|
|
200
|
+
status: 'unsupported',
|
|
201
|
+
reason: expect.stringContaining('no authorization-server metadata'),
|
|
202
|
+
});
|
|
203
|
+
warn.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
154
206
|
it('short-circuits on an already-persisted registration — never re-registers a client', async () => {
|
|
155
207
|
const existing: OAuthProviderConfig = {
|
|
156
208
|
authorizationUrl: 'https://auth.example.com/authorize',
|
|
@@ -25,6 +25,7 @@ const toolManualService = {
|
|
|
25
25
|
name: 'weather',
|
|
26
26
|
path: TOOL_PATH,
|
|
27
27
|
type: 'mcp' as const,
|
|
28
|
+
setup: { kind: 'oauth-manual' as const, reason: 'no authorization-server metadata at https://weather.example' },
|
|
28
29
|
variables: [
|
|
29
30
|
{ name: 'SHARED_KEY', scope: 'admin' as const, label: 'Org key' },
|
|
30
31
|
{
|
|
@@ -37,6 +38,19 @@ const toolManualService = {
|
|
|
37
38
|
clientId: 'client-1',
|
|
38
39
|
},
|
|
39
40
|
},
|
|
41
|
+
{
|
|
42
|
+
name: 'LEGACY',
|
|
43
|
+
scope: 'user' as const,
|
|
44
|
+
oauth: {
|
|
45
|
+
authorizationUrl: 'https://auth.example.com/authorize',
|
|
46
|
+
tokenUrl: 'https://auth.example.com/token',
|
|
47
|
+
clientId: 'client-2',
|
|
48
|
+
pkce: false,
|
|
49
|
+
resource: 'https://weather.example/mcp',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
// Declared by client id alone, and discovery could NOT fill the endpoints in.
|
|
53
|
+
{ name: 'BYO', scope: 'user' as const, oauth: { clientId: 'owner-app' } },
|
|
40
54
|
],
|
|
41
55
|
},
|
|
42
56
|
],
|
|
@@ -125,11 +139,44 @@ describe('tool owner gate — shared config requires WRITE on the `.tool` file',
|
|
|
125
139
|
expect.objectContaining({
|
|
126
140
|
key: 'weather_SIGNIN',
|
|
127
141
|
clientSecret: 'cs-123',
|
|
128
|
-
|
|
142
|
+
// PKCE rides along by default — the MCP spec requires it and a
|
|
143
|
+
// provider without it ignores the parameters. No `resource` declared.
|
|
144
|
+
provider: expect.objectContaining({ clientId: 'client-1', pkce: true, resource: undefined }),
|
|
129
145
|
}),
|
|
130
146
|
);
|
|
131
147
|
});
|
|
132
148
|
|
|
149
|
+
it('pins the declaration\'s PKCE opt-out and resource indicator with the secret', async () => {
|
|
150
|
+
const base = await baseUrlAs(WRITER);
|
|
151
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/LEGACY/oauth/admin`, {
|
|
152
|
+
method: 'PUT',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ clientSecret: 'cs-456' }),
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(201);
|
|
157
|
+
expect(putSharedOAuthClientSecret).toHaveBeenCalledWith(
|
|
158
|
+
expect.objectContaining({
|
|
159
|
+
key: 'weather_LEGACY',
|
|
160
|
+
provider: expect.objectContaining({ clientId: 'client-2', pkce: false, resource: 'https://weather.example/mcp' }),
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('refuses the secret while the sign-in endpoints are still unknown, naming why (422)', async () => {
|
|
166
|
+
const base = await baseUrlAs(WRITER);
|
|
167
|
+
const res = await fetch(`${base}/api/secrets/tools/weather/vars/BYO/oauth/admin`, {
|
|
168
|
+
method: 'PUT',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ clientSecret: 'cs-789' }),
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(422);
|
|
173
|
+
const body = (await res.json()) as { error: string };
|
|
174
|
+
expect(body.error).toContain('no authorization-server metadata at https://weather.example');
|
|
175
|
+
// Pinning a provider with no endpoints would make every later sign-in
|
|
176
|
+
// fail with a far vaguer error than this one.
|
|
177
|
+
expect(putSharedOAuthClientSecret).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
133
180
|
it('a non-writer cannot set the client secret (403)', async () => {
|
|
134
181
|
const base = await baseUrlAs(READER);
|
|
135
182
|
const res = await fetch(`${base}/api/secrets/tools/weather/vars/SIGNIN/oauth/admin`, {
|