@bevel-software/platform-core-backend 0.12.1 → 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 +5 -3
- 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/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 +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -0
- package/dist/modules/workflow/workflow.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 +4 -3
- 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__/locking-filesystem.test.ts +306 -131
- package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
- 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 +600 -582
- 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/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 +5 -1
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
- package/src/modules/workspace/workspace.tools.ts +35 -15
|
@@ -3,6 +3,9 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import { parse as parseYaml } from 'yaml';
|
|
4
4
|
import '@utcp/http'; // side effect: register the 'http' call-template type (http + inline sub-manuals)
|
|
5
5
|
import '@utcp/mcp'; // side effect: register the 'mcp' call-template type (mcp `.tool` sources)
|
|
6
|
+
// side effect: register the 'cli' call-template type for PARSING ONLY — the
|
|
7
|
+
// executor is removed again, so this process cannot dispatch a shell command.
|
|
8
|
+
import { containsCliCallTemplate } from './utcp-cli-parse-only.js';
|
|
6
9
|
import {
|
|
7
10
|
UtcpManualSerializer,
|
|
8
11
|
CallTemplateSerializer,
|
|
@@ -103,19 +106,32 @@ const variableSubstitutor = new DefaultVariableSubstitutor();
|
|
|
103
106
|
* the vault applies in the other direction with `VariableScopeResolver`.
|
|
104
107
|
*/
|
|
105
108
|
export interface McpAuthDiscoveryPort {
|
|
106
|
-
statusFor(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
| { status: 'unsupported'; reason: string }
|
|
116
|
-
>;
|
|
109
|
+
statusFor(manualName: string, mcpUrl: string): Promise<McpAuthDiscoveryResult>;
|
|
110
|
+
/**
|
|
111
|
+
* The sign-in endpoints for an OWNER-REGISTERED client: the same metadata
|
|
112
|
+
* walk as `statusFor`, stopping short of dynamic registration — the manual
|
|
113
|
+
* already names its `clientId`. Nothing is persisted; the owner's
|
|
114
|
+
* client-secret save pins the completed provider. Optional so a port that
|
|
115
|
+
* only knows the zero-config path still satisfies the interface.
|
|
116
|
+
*/
|
|
117
|
+
providerForDeclaredClient?(manualName: string, mcpUrl: string, clientId: string): Promise<McpAuthDiscoveryResult>;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
export type McpAuthDiscoveryResult =
|
|
121
|
+
| { status: 'open' }
|
|
122
|
+
| {
|
|
123
|
+
status: 'oauth';
|
|
124
|
+
provider: {
|
|
125
|
+
authorizationUrl: string;
|
|
126
|
+
tokenUrl: string;
|
|
127
|
+
clientId: string;
|
|
128
|
+
scopes?: string[];
|
|
129
|
+
resource?: string;
|
|
130
|
+
pkce?: boolean;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
| { status: 'unsupported'; reason: string };
|
|
134
|
+
|
|
119
135
|
/**
|
|
120
136
|
* Reads `*.tool` manuals from the DEFAULT-branch workspace (never the caller's
|
|
121
137
|
* branch), so the catalog is one global, released set — same discipline as
|
|
@@ -172,9 +188,9 @@ export class ToolManualService implements IToolManualService {
|
|
|
172
188
|
};
|
|
173
189
|
}
|
|
174
190
|
|
|
175
|
-
async listLocalOnly(userEmail: string): Promise<{ name: string; path: string }[]> {
|
|
191
|
+
async listLocalOnly(userEmail: string): Promise<{ slug: string; name: string; path: string }[]> {
|
|
176
192
|
const manuals = await this.accessibleManuals(userEmail);
|
|
177
|
-
return manuals.filter((m) => m.remote === false).map((m) => ({ name: m.name, path: m.path }));
|
|
193
|
+
return manuals.filter((m) => m.remote === false).map((m) => ({ slug: m.slug, name: m.name, path: m.path }));
|
|
178
194
|
}
|
|
179
195
|
|
|
180
196
|
async userScopedKeysForManual(
|
|
@@ -426,21 +442,31 @@ export class ToolManualService implements IToolManualService {
|
|
|
426
442
|
private async decorateMcpOAuth(manuals: ToolManualDescriptor[]): Promise<void> {
|
|
427
443
|
const discovery = this.mcpAuthDiscovery;
|
|
428
444
|
if (!discovery) return;
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
445
|
+
const bare: ToolManualDescriptor[] = [];
|
|
446
|
+
const declared: { m: ToolManualDescriptor; v: ToolVariable }[] = [];
|
|
447
|
+
for (const m of manuals) {
|
|
448
|
+
if (m.type !== 'mcp') continue;
|
|
449
|
+
const oauthVar = (m.variables ?? []).find((v) => v.oauth != null);
|
|
450
|
+
if (oauthVar) {
|
|
451
|
+
// An owner-registered client is `oauth-manual` by definition — whether
|
|
452
|
+
// the declaration is complete or still needs its endpoints discovered.
|
|
453
|
+
// Explicit wins over discovery: never registered over, never probed
|
|
454
|
+
// for anything but the endpoints the declaration left out.
|
|
455
|
+
m.setup = { kind: 'oauth-manual' };
|
|
456
|
+
const o = oauthVar.oauth!;
|
|
457
|
+
if (!o.authorizationUrl || !o.tokenUrl) declared.push({ m, v: oauthVar });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
434
460
|
// The file configures auth itself — explicit wins over discovery.
|
|
435
461
|
const hasAuthHeader = Object.keys(m.headers ?? {}).some((h) => h.toLowerCase() === 'authorization');
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
});
|
|
462
|
+
if (!hasAuthHeader && isProbeableMcpServer(m)) bare.push(m);
|
|
463
|
+
}
|
|
439
464
|
// Probe every eligible server CONCURRENTLY — a cold scan with several bare
|
|
440
465
|
// mcp tools shouldn't pay one network round-trip per tool in series. The
|
|
441
466
|
// mutation still happens per-manual after its own probe settles.
|
|
442
|
-
await Promise.all(
|
|
443
|
-
|
|
467
|
+
await Promise.all([
|
|
468
|
+
...declared.map(({ m, v }) => this.completeDeclaredOAuth(discovery, m, v)),
|
|
469
|
+
...bare.map(async (m) => {
|
|
444
470
|
try {
|
|
445
471
|
const found = await discovery.statusFor(m.name, m.url!);
|
|
446
472
|
// Record the setup requirement so the secrets UI can tell an admin
|
|
@@ -485,7 +511,62 @@ export class ToolManualService implements IToolManualService {
|
|
|
485
511
|
);
|
|
486
512
|
}
|
|
487
513
|
}),
|
|
488
|
-
);
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* "Bring your own client": a declared sign-in that names only its `clientId`
|
|
519
|
+
* (the owner registered an app with a provider that offers no dynamic
|
|
520
|
+
* registration — HubSpot, Google) gets its endpoints, PKCE and resource
|
|
521
|
+
* indicator from the server's own OAuth metadata, exactly as the zero-config
|
|
522
|
+
* path would. The descriptor is completed IN MEMORY: the client-secret route
|
|
523
|
+
* reads the completed declaration and pins it with the secret, so nothing
|
|
524
|
+
* here persists. When the metadata can't be had, the declaration stays
|
|
525
|
+
* incomplete and `setup.reason` says so — the secret route then refuses
|
|
526
|
+
* with the same reason instead of pinning a provider with no endpoints.
|
|
527
|
+
*/
|
|
528
|
+
private async completeDeclaredOAuth(
|
|
529
|
+
discovery: McpAuthDiscoveryPort,
|
|
530
|
+
m: ToolManualDescriptor,
|
|
531
|
+
v: ToolVariable,
|
|
532
|
+
): Promise<void> {
|
|
533
|
+
const declaredByHand = 'declare `authorizationUrl` and `tokenUrl` on the sign-in variable';
|
|
534
|
+
if (!isProbeableMcpServer(m)) {
|
|
535
|
+
m.setup = {
|
|
536
|
+
kind: 'oauth-manual',
|
|
537
|
+
reason: `the sign-in endpoints can't be discovered for a local-only or templated server URL — ${declaredByHand}`,
|
|
538
|
+
};
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!discovery.providerForDeclaredClient) {
|
|
542
|
+
m.setup = { kind: 'oauth-manual', reason: `sign-in endpoint discovery is unavailable — ${declaredByHand}` };
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
const found = await discovery.providerForDeclaredClient(m.name, m.url!, v.oauth!.clientId);
|
|
547
|
+
if (found.status !== 'oauth') {
|
|
548
|
+
m.setup = {
|
|
549
|
+
kind: 'oauth-manual',
|
|
550
|
+
reason:
|
|
551
|
+
found.status === 'unsupported'
|
|
552
|
+
? found.reason
|
|
553
|
+
: `the server did not ask for a sign-in and publishes no OAuth metadata — ${declaredByHand} if it needs one`,
|
|
554
|
+
};
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
v.oauth = {
|
|
558
|
+
...v.oauth!,
|
|
559
|
+
authorizationUrl: found.provider.authorizationUrl,
|
|
560
|
+
tokenUrl: found.provider.tokenUrl,
|
|
561
|
+
// A hand-declared resource wins; otherwise the server's own canonical URL.
|
|
562
|
+
...(!v.oauth!.resource && found.provider.resource ? { resource: found.provider.resource } : {}),
|
|
563
|
+
};
|
|
564
|
+
} catch (err) {
|
|
565
|
+
// Never break the catalog — the sign-in just isn't ready yet.
|
|
566
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
567
|
+
console.warn(`[tool-manuals] sign-in endpoint discovery failed for "${m.path}": ${msg}`);
|
|
568
|
+
m.setup = { kind: 'oauth-manual', reason: `sign-in endpoint discovery failed: ${msg}` };
|
|
569
|
+
}
|
|
489
570
|
}
|
|
490
571
|
|
|
491
572
|
private async scanDisk(): Promise<ToolManualDescriptor[]> {
|
|
@@ -556,10 +637,19 @@ export class ToolManualService implements IToolManualService {
|
|
|
556
637
|
// silently rebind a configured secret to a different file. The winner is
|
|
557
638
|
// deterministic (files scanned in sorted path order); the shared `dedupeById`
|
|
558
639
|
// is the one dedup rule across tools and skills.
|
|
559
|
-
|
|
640
|
+
// Deduped by NAMESPACE, not by name. The comment below has always said the
|
|
641
|
+
// id is the secret-variable namespace and must be unique — but the check
|
|
642
|
+
// compared raw names, and the two are not the same function. Namespacing
|
|
643
|
+
// maps every non-word character to `_` and then doubles it, so `a-b` and
|
|
644
|
+
// `a_b` are different names with the SAME namespace `a__b_`. A `.tool` id
|
|
645
|
+
// cannot contain a hyphen, but an mcp.json server name can, so the pair is
|
|
646
|
+
// reachable — and the consequence is that two manuals share one set of
|
|
647
|
+
// vault keys, with either able to resolve the other's secrets.
|
|
648
|
+
return dedupeById(parsed, (m) => utcpNamespacePrefix(m.name), (m, ns) =>
|
|
560
649
|
console.warn(
|
|
561
|
-
`[tool-manuals] skipping "${m.path}": manual
|
|
562
|
-
|
|
650
|
+
`[tool-manuals] skipping "${m.path}": manual "${m.name}" resolves to the secret-variable ` +
|
|
651
|
+
`namespace "${ns}", which another manual already uses. Names differing only in \`-\` vs \`_\` ` +
|
|
652
|
+
'share one namespace — rename one of them.',
|
|
563
653
|
),
|
|
564
654
|
);
|
|
565
655
|
}
|
|
@@ -714,6 +804,23 @@ export function normalizeToolManual(
|
|
|
714
804
|
|
|
715
805
|
descriptor.remote = normalizeRemote(obj.remote);
|
|
716
806
|
|
|
807
|
+
// A `.tool` that shells out is LOCAL, always. The hosted platform parses and
|
|
808
|
+
// lists these so the local MCP server can find them, but it must never be the
|
|
809
|
+
// thing that runs them — a `.tool` is knowledge-base content, and agents write
|
|
810
|
+
// to the knowledge base. A declared `remote: true` beside a shell command is
|
|
811
|
+
// therefore a refusal rather than a warning: silently correcting it would let
|
|
812
|
+
// an author believe they had published a remote tool, and would leave the
|
|
813
|
+
// catalog disagreeing with the file about what the platform will do.
|
|
814
|
+
if (containsCliCallTemplate(obj)) {
|
|
815
|
+
if (obj.remote === true) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`\`.tool\` "${name}" declares \`remote: true\` but contains a \`cli\` call template — ` +
|
|
818
|
+
'shell tools execute only in a local runtime (drop `remote: true`, or the `cli` template).',
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
descriptor.remote = false;
|
|
822
|
+
}
|
|
823
|
+
|
|
717
824
|
if (type === 'inline') {
|
|
718
825
|
const tools = Array.isArray(obj.tools) ? obj.tools : undefined;
|
|
719
826
|
if (!tools) throw new Error('inline `.tool` must have a `tools` array');
|
|
@@ -812,11 +919,23 @@ function normalizeVariables(raw: unknown): ToolVariable[] {
|
|
|
812
919
|
});
|
|
813
920
|
}
|
|
814
921
|
|
|
922
|
+
/**
|
|
923
|
+
* Whether the platform can reach an MCP server's URL for OAuth discovery.
|
|
924
|
+
* Local-only servers aren't probeable from here; templated URLs aren't
|
|
925
|
+
* resolvable without a caller. Both keep their file-declared behavior — the
|
|
926
|
+
* one rule for the zero-config probe and for completing a declared client.
|
|
927
|
+
*/
|
|
928
|
+
function isProbeableMcpServer(m: ToolManualDescriptor): boolean {
|
|
929
|
+
return !!m.url && m.remote !== false && !m.url.includes('${');
|
|
930
|
+
}
|
|
931
|
+
|
|
815
932
|
/**
|
|
816
933
|
* Parse a variable's optional OAuth provider config. Carries PUBLIC config only —
|
|
817
934
|
* never a client secret. Both URLs are validated with the SAME SSRF-safe check the
|
|
818
935
|
* vault uses for OAuth endpoints (`assertSafeFetchUrl` with https required), so a
|
|
819
|
-
* `.tool` author can't aim a sign-in/token exchange at an internal host.
|
|
936
|
+
* `.tool` author can't aim a sign-in/token exchange at an internal host. Both
|
|
937
|
+
* URLs are REQUIRED here: a `.tool` (http/inline) has no server whose OAuth
|
|
938
|
+
* metadata could fill them in — that convenience belongs to mcp.json servers.
|
|
820
939
|
*/
|
|
821
940
|
function normalizeVariableOAuth(name: string, raw: unknown): ToolVariableOAuth | undefined {
|
|
822
941
|
if (raw === undefined || raw === null) return undefined;
|
|
@@ -862,12 +981,21 @@ function normalizeVariableOAuth(name: string, raw: unknown): ToolVariableOAuth |
|
|
|
862
981
|
}
|
|
863
982
|
authParams = Object.fromEntries(entries) as Record<string, string>;
|
|
864
983
|
}
|
|
984
|
+
// PKCE is on unless the file says `false`; only the opt-out is ever stored.
|
|
985
|
+
if (o.pkce !== undefined && typeof o.pkce !== 'boolean') {
|
|
986
|
+
throw new Error(`variable "${name}" oauth.pkce must be a boolean`);
|
|
987
|
+
}
|
|
988
|
+
// Never fetched (it rides as a request param), but it names the remote
|
|
989
|
+
// server — same https/SSRF bar as the endpoints.
|
|
990
|
+
const resource = o.resource !== undefined ? safeUrl(o.resource, 'resource') : undefined;
|
|
865
991
|
return {
|
|
866
992
|
authorizationUrl,
|
|
867
993
|
tokenUrl,
|
|
868
994
|
clientId,
|
|
869
995
|
...(scopes ? { scopes } : {}),
|
|
870
996
|
...(authParams ? { authParams } : {}),
|
|
997
|
+
...(o.pkce === false ? { pkce: false } : {}),
|
|
998
|
+
...(resource ? { resource } : {}),
|
|
871
999
|
};
|
|
872
1000
|
}
|
|
873
1001
|
|
|
@@ -69,9 +69,12 @@ export function registerToolManualsTools(
|
|
|
69
69
|
'what is already configured. Results are scoped to the caller — a `.tool` the caller cannot READ is ' +
|
|
70
70
|
'absent entirely, and all status flags reflect the caller\'s own state. Per tool: `setup` describes ' +
|
|
71
71
|
'an MCP server\'s sign-in requirement (`open` = none; `oauth-auto` = sign-in was configured ' +
|
|
72
|
-
'automatically; `oauth-manual` = the
|
|
73
|
-
'
|
|
74
|
-
'
|
|
72
|
+
'automatically; `oauth-manual` = the sign-in needs an OAuth app the owner registers with the provider: ' +
|
|
73
|
+
'a writer declares its client id on a `user`-scoped variable with an `oauth` block — in the plugin.json ' +
|
|
74
|
+
'extensions entry for an mcp.json server (endpoints are discovered from the server; PKCE is on by ' +
|
|
75
|
+
'default), or in the `.tool` file with explicit URLs — and pastes the client secret on the tool\'s page. ' +
|
|
76
|
+
'`setup.reason` is present only while something still blocks the sign-in and says what). ' +
|
|
77
|
+
'Per variable: whether the shared (admin) value is set, whether the CURRENT user has ' +
|
|
75
78
|
'set/authorized their own, and whether it is an OAuth sign-in (users authorize those on the /connect ' +
|
|
76
79
|
'page, never by typing a value). `canWrite` = the caller may write THAT `.tool` FILE (per-file access ' +
|
|
77
80
|
'from its frontmatter `write:`/`owner:` verbs and the access.md chain — NOT a platform role), which ' +
|
|
@@ -181,8 +184,9 @@ async function buildListLocalToolsDef(svc: IToolManualService, userEmail?: strin
|
|
|
181
184
|
'(e.g. a self-hosted MCP server on localhost) and therefore cannot be called through this ' +
|
|
182
185
|
'remote endpoint. To CALL them, run the workspace as a local MCP server instead of this one: ' +
|
|
183
186
|
'`npx @bevel-software/hexis-mcp --url <workspace-url> --key <connection-key>` serves every tool ' +
|
|
184
|
-
'you have here plus these, because it runs where they exist
|
|
185
|
-
'
|
|
187
|
+
'you have here plus these, because it runs where they exist — and it resolves each tool\'s ' +
|
|
188
|
+
'declared variables from this workspace\'s secrets, so nothing has to be hand-placed on that ' +
|
|
189
|
+
'machine. Otherwise each entry gives the tool’s name and its `.tool` file ' +
|
|
186
190
|
'path in the knowledge base — read that file with `read_file` and wire the tool into your local ' +
|
|
187
191
|
'setup by hand. ' +
|
|
188
192
|
(await localToolsLine(svc, userEmail)),
|
|
@@ -197,6 +201,7 @@ async function buildListLocalToolsDef(svc: IToolManualService, userEmail?: strin
|
|
|
197
201
|
items: {
|
|
198
202
|
type: 'object',
|
|
199
203
|
properties: {
|
|
204
|
+
slug: { type: 'string', description: 'How the tool is addressed on this API.' },
|
|
200
205
|
name: { type: 'string' },
|
|
201
206
|
path: { type: 'string', description: 'KB path of the `.tool` file (read it with read_file).' },
|
|
202
207
|
},
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the UTCP `cli` call-template type for PARSING ONLY, and takes the
|
|
3
|
+
* executor away again.
|
|
4
|
+
*
|
|
5
|
+
* A `.tool` whose tools shell out (`git status`, `git push`) is how the local
|
|
6
|
+
* MCP server gets a git surface without handing an agent a token. The hosted
|
|
7
|
+
* platform still has to UNDERSTAND those files — it scans them, validates them,
|
|
8
|
+
* lists them through `list_local_tools`, and serves the inline manual body to
|
|
9
|
+
* the local server that will run it — so the `cli` serializer must be
|
|
10
|
+
* registered here or every such file fails validation and drops out of the
|
|
11
|
+
* catalog.
|
|
12
|
+
*
|
|
13
|
+
* What it must never do is RUN one. Agents write to the knowledge base, so
|
|
14
|
+
* anyone who can land a `.tool` would otherwise have a shell on production.
|
|
15
|
+
* `@utcp/cli`'s module-level `register()` installs two things — the call
|
|
16
|
+
* template serializer and a `CliCommunicationProtocol` — and only the first is
|
|
17
|
+
* wanted here, so the protocol is removed from the SDK's registry immediately
|
|
18
|
+
* after the import that added it.
|
|
19
|
+
*
|
|
20
|
+
* That makes decision 8 of the AEL plan structural instead of procedural. The
|
|
21
|
+
* `remote: false` forcing in `normalizeToolManual` keeps cli manuals out of the
|
|
22
|
+
* hosted proxy's registration set, but this is the backstop underneath it: with
|
|
23
|
+
* no `cli` protocol in the process, a cli template that reached a hosted client
|
|
24
|
+
* by any route at all fails to dispatch rather than executing. Two independent
|
|
25
|
+
* mechanisms, because one of them being wrong is a remote shell.
|
|
26
|
+
*
|
|
27
|
+
* Import this module for its side effect wherever `.tool` files are parsed;
|
|
28
|
+
* import order does not matter, since the removal runs at module evaluation and
|
|
29
|
+
* nothing dispatches during a scan.
|
|
30
|
+
*/
|
|
31
|
+
import '@utcp/cli';
|
|
32
|
+
import { CommunicationProtocol } from '@utcp/sdk';
|
|
33
|
+
|
|
34
|
+
/** The UTCP call-template type this module registers the serializer for. */
|
|
35
|
+
export const CLI_CALL_TEMPLATE_TYPE = 'cli';
|
|
36
|
+
|
|
37
|
+
delete (CommunicationProtocol.communicationProtocols as Record<string, unknown>)[CLI_CALL_TEMPLATE_TYPE];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether this process could dispatch a `cli` call template. Always false in the
|
|
41
|
+
* hosted platform — exported so the guarantee is assertable in a test rather
|
|
42
|
+
* than only described in a comment.
|
|
43
|
+
*/
|
|
44
|
+
export function canExecuteCliTemplates(): boolean {
|
|
45
|
+
return CommunicationProtocol.communicationProtocols[CLI_CALL_TEMPLATE_TYPE] !== undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when any part of `doc` is a `cli` call template.
|
|
50
|
+
*
|
|
51
|
+
* Deep walk rather than a lookup at `tool_call_template.call_template_type`:
|
|
52
|
+
* the field is nested differently across UTCP tool shapes, sub-manuals nest
|
|
53
|
+
* templates inside templates, and the consequence of missing one is a shell
|
|
54
|
+
* template in a manual marked remote-capable. Cheap, and a `.tool` is small.
|
|
55
|
+
*
|
|
56
|
+
* Cycle-safe. A YAML anchor aliased inside itself (`a: &x { self: *x }`)
|
|
57
|
+
* parses to a genuinely cyclic object, and an unguarded walk overflows the
|
|
58
|
+
* stack — which the callers catch, so the file would be dropped from the
|
|
59
|
+
* catalog silently instead of being examined. A node already seen contributes
|
|
60
|
+
* nothing new, so it is simply not re-entered.
|
|
61
|
+
*/
|
|
62
|
+
export function containsCliCallTemplate(doc: unknown): boolean {
|
|
63
|
+
const seen = new WeakSet<object>();
|
|
64
|
+
const walk = (node: unknown): boolean => {
|
|
65
|
+
if (!node || typeof node !== 'object') return false;
|
|
66
|
+
if (seen.has(node)) return false;
|
|
67
|
+
seen.add(node);
|
|
68
|
+
if (Array.isArray(node)) return node.some(walk);
|
|
69
|
+
const obj = node as Record<string, unknown>;
|
|
70
|
+
if (typeof obj.call_template_type === 'string' && obj.call_template_type.toLowerCase().trim() === CLI_CALL_TEMPLATE_TYPE) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
return Object.values(obj).some(walk);
|
|
74
|
+
};
|
|
75
|
+
return walk(doc);
|
|
76
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
PendingCommit,
|
|
14
14
|
PendingCommitsService,
|
|
15
15
|
} from '../pending-commits.service.js';
|
|
16
|
+
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Unit tests for the queue-draining worker. The service + workflow layers
|
|
@@ -20,6 +21,8 @@ import type {
|
|
|
20
21
|
* applies per claimed row:
|
|
21
22
|
*
|
|
22
23
|
* - Success → markSucceeded, nothing else.
|
|
24
|
+
* - Failure because the path is outside the repository → needs_attention +
|
|
25
|
+
* feedback notice at once, whatever the budgets say.
|
|
23
26
|
* - Failure inside transient budget → markTransientFailure, no recovery agent.
|
|
24
27
|
* - Failure outside transient budget but inside recovery budget → spawn agent.
|
|
25
28
|
* - Failure outside recovery budget → mark needs_attention + send feedback notice.
|
|
@@ -69,6 +72,16 @@ function makeWorkspaces(): WorkspaceProvider {
|
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** The error `commitFile` throws for a path beside the clone, as the real producer builds it. */
|
|
76
|
+
function captureRefusal(wsPath: string): unknown {
|
|
77
|
+
try {
|
|
78
|
+
assertInsideRepo(wsPath, 'knowledge-base');
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return err;
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`expected assertInsideRepo to refuse "${wsPath}"`);
|
|
83
|
+
}
|
|
84
|
+
|
|
72
85
|
function makeFeedback(): ISystemNoticeSink {
|
|
73
86
|
return { send: vi.fn().mockResolvedValue(undefined) } as unknown as ISystemNoticeSink;
|
|
74
87
|
}
|
|
@@ -215,6 +228,39 @@ describe('PendingCommitsWorker.drainOnce', () => {
|
|
|
215
228
|
expect(call.message).toContain('alice@example.com');
|
|
216
229
|
});
|
|
217
230
|
|
|
231
|
+
it('a path-outside-repo refusal escalates at once: no retry, no recovery agent, the notice names the corrected path', async () => {
|
|
232
|
+
// A fresh row with both budgets untouched: any other failure would be
|
|
233
|
+
// retried. This one cannot change on retry (the bytes are beside the
|
|
234
|
+
// clone, where git never looks) and gives a recovery agent nothing to
|
|
235
|
+
// repair, so the ladder is skipped and the row is flagged immediately.
|
|
236
|
+
const row = makeRow({ path: 'KnowledgeBase/Reviews/PR-12.html', attempts: 0, recoveryAgentRuns: 0 });
|
|
237
|
+
(service.claimNext as ReturnType<typeof vi.fn>).mockResolvedValueOnce(row);
|
|
238
|
+
// The commit layer's own refusal, built by its producer so the shape the
|
|
239
|
+
// worker switches on cannot drift from what is actually thrown.
|
|
240
|
+
workflow.runPendingCommit.mockRejectedValueOnce(captureRefusal('KnowledgeBase/Reviews/PR-12.html'));
|
|
241
|
+
|
|
242
|
+
await worker.drainOnce();
|
|
243
|
+
|
|
244
|
+
expect(service.markTransientFailure).not.toHaveBeenCalled();
|
|
245
|
+
expect(service.markRecoveryStarted).not.toHaveBeenCalled();
|
|
246
|
+
expect(recoveryAgent.run).not.toHaveBeenCalled();
|
|
247
|
+
// `last_error` gets the sanitized message (truncated to fit the column).
|
|
248
|
+
expect(service.markNeedsAttention).toHaveBeenCalledWith(
|
|
249
|
+
'row-1',
|
|
250
|
+
expect.stringContaining('"KnowledgeBase/Reviews/PR-12.html" is outside the knowledge base repository'),
|
|
251
|
+
);
|
|
252
|
+
expect(feedback.send).toHaveBeenCalledTimes(1);
|
|
253
|
+
const call = (feedback.send as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
254
|
+
expect(call.source).toBe('system');
|
|
255
|
+
expect(call.user).toEqual(RECOVERY_BOT);
|
|
256
|
+
expect(call.message).toContain('outside the repository');
|
|
257
|
+
expect(call.message).toContain('Path: KnowledgeBase/Reviews/PR-12.html');
|
|
258
|
+
// The correction survives the truncation: it rides on the error payload,
|
|
259
|
+
// not on the message.
|
|
260
|
+
expect(call.message).toContain('Use instead: knowledge-base/KnowledgeBase/Reviews/PR-12.html');
|
|
261
|
+
expect(call.message).toContain('alice@example.com');
|
|
262
|
+
});
|
|
263
|
+
|
|
218
264
|
it('a feedback-sink failure does not throw — the row stays needs_attention regardless', async () => {
|
|
219
265
|
const row = makeRow({ attempts: N_TRANSIENT - 1, recoveryAgentRuns: N_RECOVERY });
|
|
220
266
|
(service.claimNext as ReturnType<typeof vi.fn>).mockResolvedValueOnce(row);
|
|
@@ -42,6 +42,13 @@ const workflowService = {
|
|
|
42
42
|
calls.push(['createBranch', ws, name]);
|
|
43
43
|
return { name, isProtected: false, ahead: 0, behind: 0, hasRemote: true };
|
|
44
44
|
},
|
|
45
|
+
acquireLock: async (ws: string, branch: string, path: string) => {
|
|
46
|
+
calls.push(['acquireLock', ws, branch, path]);
|
|
47
|
+
return { acquired: true, lock: { branch, path, holderUserId: 'user-A', holderName: 'N' } };
|
|
48
|
+
},
|
|
49
|
+
releaseLock: async (ws: string, branch: string, path: string) => {
|
|
50
|
+
calls.push(['releaseLock', ws, branch, path]);
|
|
51
|
+
},
|
|
45
52
|
} as never;
|
|
46
53
|
const events = {
|
|
47
54
|
emit: (p: unknown) => {
|
|
@@ -52,15 +59,18 @@ const events = {
|
|
|
52
59
|
|
|
53
60
|
const internalToken = new InternalTokenService({ secret: 's' });
|
|
54
61
|
let httpServer: HttpServer | undefined;
|
|
62
|
+
/** The registry the tools were mounted into, so a test can inspect their definitions. */
|
|
63
|
+
let registryRef: ToolRegistry | undefined;
|
|
55
64
|
|
|
56
65
|
async function start(): Promise<string> {
|
|
57
66
|
const registry = new ToolRegistry();
|
|
67
|
+
registryRef = registry;
|
|
58
68
|
const toolAuth = createToolAuthMiddleware(externalApiKeyService, internalToken);
|
|
59
69
|
const resolve = createToolContextResolver({ authService, workspaceService, workflowService, events, kbDirName: 'knowledge-base', creatorAccess: { planForCreate: async () => null, grantInExtractedFile: async () => null, noteAccessFileWritten: () => {} } });
|
|
60
70
|
const toolHandler = createToolHandlerFactory(resolve);
|
|
61
71
|
|
|
62
72
|
const router = express.Router();
|
|
63
|
-
registerWorkflowTools(registry, router, toolAuth, toolHandler);
|
|
73
|
+
registerWorkflowTools(registry, router, toolAuth, toolHandler, 'knowledge-base');
|
|
64
74
|
router.use(createManualRoutes(registry, toolAuth));
|
|
65
75
|
|
|
66
76
|
const app = express();
|
|
@@ -190,3 +200,52 @@ describe('registerWorkflowTools', () => {
|
|
|
190
200
|
expect(external).not.toContain('switch_branch'); // internal-only
|
|
191
201
|
});
|
|
192
202
|
});
|
|
203
|
+
|
|
204
|
+
describe('save_file and the repository folder', () => {
|
|
205
|
+
// `save_file` commits whatever is on disk at `path` through the lock
|
|
206
|
+
// protocol, bypassing the locking filesystem's own guard. A path without the
|
|
207
|
+
// clone-folder prefix names a file git can never see, so it is refused here,
|
|
208
|
+
// before a lock is taken, with the same corrected-path message the write
|
|
209
|
+
// tools give.
|
|
210
|
+
it('refuses a repo-relative path before taking any lock', async () => {
|
|
211
|
+
const base = await start();
|
|
212
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), {
|
|
213
|
+
path: 'KnowledgeBase/Reviews/PR-12.html',
|
|
214
|
+
branch: WS,
|
|
215
|
+
});
|
|
216
|
+
expect(res.status).toBe(400);
|
|
217
|
+
const body = (await res.json()) as { error: string };
|
|
218
|
+
expect(body.error).toContain('"knowledge-base/KnowledgeBase/Reviews/PR-12.html"');
|
|
219
|
+
expect(calls.some((c) => c[0] === 'acquireLock')).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('answers a missing path with a 400, not a crash', async () => {
|
|
223
|
+
const base = await start();
|
|
224
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), { branch: WS });
|
|
225
|
+
expect(res.status).toBe(400);
|
|
226
|
+
const body = (await res.json()) as { error: string };
|
|
227
|
+
expect(body.error).toMatch(/path/i);
|
|
228
|
+
expect(calls.some((c) => c[0] === 'acquireLock')).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('schedules a prefixed path as before', async () => {
|
|
232
|
+
const base = await start();
|
|
233
|
+
const res = await post(`${base}/api/agent/tools/save_file`, writeTok(), {
|
|
234
|
+
path: 'knowledge-base/KnowledgeBase/Reviews/PR-12.html',
|
|
235
|
+
branch: WS,
|
|
236
|
+
});
|
|
237
|
+
expect(res.status).toBe(200);
|
|
238
|
+
expect(await res.json()).toMatchObject({ saved: true, queued: true });
|
|
239
|
+
expect(calls).toContainEqual(['acquireLock', WS, WS, 'knowledge-base/KnowledgeBase/Reviews/PR-12.html']);
|
|
240
|
+
expect(calls).toContainEqual(['releaseLock', WS, WS, 'knowledge-base/KnowledgeBase/Reviews/PR-12.html']);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('describes the prefix on its path input', async () => {
|
|
244
|
+
await start();
|
|
245
|
+
const tools = await registryRef!.listInternal();
|
|
246
|
+
const def = tools.find((t) => t.name === 'save_file');
|
|
247
|
+
// `toolDef` wraps a tool's inputs under a single `body` property.
|
|
248
|
+
const body = (def!.inputs as { properties: { body: { properties: Record<string, { description?: string }> } } }).properties.body;
|
|
249
|
+
expect(body.properties.path.description).toContain('`knowledge-base/`');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -6,6 +6,7 @@ import { toolDef, withBranchInput } from '../../tool-helpers/tool-def.js';
|
|
|
6
6
|
import type { ToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
|
|
7
7
|
import { requireInternalSource } from '../../tool-auth/tool-auth.middleware.js';
|
|
8
8
|
import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
|
|
9
|
+
import { assertInsideRepo } from '../../kb-fs/repo-path.js';
|
|
9
10
|
|
|
10
11
|
// A function, not a constant: the branch model is applied during boot, and a
|
|
11
12
|
// module-scope capture would freeze this at the empty set that exists before it.
|
|
@@ -118,6 +119,8 @@ export function registerWorkflowTools(
|
|
|
118
119
|
router: Router,
|
|
119
120
|
toolAuth: RequestHandler,
|
|
120
121
|
toolHandler: ToolHandlerFactory,
|
|
122
|
+
/** The clone folder at the workspace root; `save_file` refuses a path outside it. */
|
|
123
|
+
kbDirName: string,
|
|
121
124
|
): void {
|
|
122
125
|
const mount = (spec: {
|
|
123
126
|
name: string;
|
|
@@ -221,7 +224,13 @@ export function registerWorkflowTools(
|
|
|
221
224
|
'`{ saved: false, reason }` rather than throwing. The commit lands asynchronously.',
|
|
222
225
|
inputs: {
|
|
223
226
|
type: 'object',
|
|
224
|
-
properties: {
|
|
227
|
+
properties: {
|
|
228
|
+
path: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
minLength: 1,
|
|
231
|
+
description: `Workspace-relative path, as write_file expects: starts with \`${kbDirName}/\` (e.g. \`${kbDirName}/KnowledgeBase/Foo.md\`).`,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
225
234
|
required: ['path'],
|
|
226
235
|
additionalProperties: false,
|
|
227
236
|
},
|
|
@@ -238,6 +247,14 @@ export function registerWorkflowTools(
|
|
|
238
247
|
handler: async (args, ctx: ToolContext) => {
|
|
239
248
|
const path = args.path as string;
|
|
240
249
|
const branch = args.branch as string;
|
|
250
|
+
// This tool commits whatever is on disk at `path` through the lock
|
|
251
|
+
// protocol, bypassing the locking filesystem's own guard. A path without
|
|
252
|
+
// the clone-folder prefix names a file git can never see: refuse it
|
|
253
|
+
// before a lock is taken, with the same corrected-path message.
|
|
254
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
255
|
+
throw new ToolError('`path` is required and must be a non-empty string.', 400);
|
|
256
|
+
}
|
|
257
|
+
assertInsideRepo(path, kbDirName);
|
|
241
258
|
const workspaceId = workspaceIdForBranch(branch);
|
|
242
259
|
const acquired = await ctx.workflowService.acquireLock(workspaceId, branch, path, ctx.user);
|
|
243
260
|
if (!acquired.acquired) {
|
|
@@ -130,6 +130,62 @@ describe('GitService.changedFilesForPr / resolvePrShas', () => {
|
|
|
130
130
|
expect(baseSha).not.toBe(headSha);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* A change-request detail resolves the SHAs (which fetches both refs) and
|
|
135
|
+
* then lists the files. `at` pins that listing to the commits just
|
|
136
|
+
* resolved: no second fetch, and the file list describes exactly the head
|
|
137
|
+
* the approvals pin against even when a newer push has landed on origin in
|
|
138
|
+
* between. The default keeps refreshing first.
|
|
139
|
+
*/
|
|
140
|
+
it('`at` pins the diff to the resolved commits; the default refreshes origin/* first', async () => {
|
|
141
|
+
const { upstream, repo } = await seedWorkspace(root, workspaceId);
|
|
142
|
+
// The branch is authored in ANOTHER clone, so this workspace only ever
|
|
143
|
+
// knows it as origin/alice/feature — the production shape, where the
|
|
144
|
+
// base-branch workspace answers for every request without a local copy.
|
|
145
|
+
const other = path.join(root, 'other');
|
|
146
|
+
await runGit(root, ['clone', upstream, other]);
|
|
147
|
+
await runGit(other, ['checkout', '-b', 'alice/feature']);
|
|
148
|
+
await fs.writeFile(path.join(other, 'added.md'), 'hello\n');
|
|
149
|
+
await runGit(other, ['add', '-A']);
|
|
150
|
+
await runGit(other, ['commit', '-m', 'feature work']);
|
|
151
|
+
await runGit(other, ['push', '-u', 'origin', 'alice/feature']);
|
|
152
|
+
|
|
153
|
+
const git = new GitService(
|
|
154
|
+
stubWorkspaceService(workspaceId, repo),
|
|
155
|
+
new WorkflowHooks(),
|
|
156
|
+
'knowledge-base',
|
|
157
|
+
);
|
|
158
|
+
// What the detail does first: resolve (and fetch) the two SHAs.
|
|
159
|
+
const pinned = await git.resolvePrShas(workspaceId, 'current-company-state', 'alice/feature');
|
|
160
|
+
|
|
161
|
+
// A second push lands on origin after the resolution.
|
|
162
|
+
await fs.writeFile(path.join(other, 'second.md'), 'more\n');
|
|
163
|
+
await runGit(other, ['add', '-A']);
|
|
164
|
+
await runGit(other, ['commit', '-m', 'more work']);
|
|
165
|
+
await runGit(other, ['push', 'origin', 'alice/feature']);
|
|
166
|
+
|
|
167
|
+
const atPinned = await git.changedFilesForPr(
|
|
168
|
+
workspaceId,
|
|
169
|
+
'current-company-state',
|
|
170
|
+
'alice/feature',
|
|
171
|
+
{ at: pinned },
|
|
172
|
+
);
|
|
173
|
+
expect(atPinned.map((f) => f.path)).toEqual(['added.md']);
|
|
174
|
+
|
|
175
|
+
const refreshed = await git.changedFilesForPr(
|
|
176
|
+
workspaceId,
|
|
177
|
+
'current-company-state',
|
|
178
|
+
'alice/feature',
|
|
179
|
+
);
|
|
180
|
+
expect(refreshed.map((f) => f.path).sort()).toEqual(['added.md', 'second.md']);
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
git.changedFilesForPr(workspaceId, 'current-company-state', 'alice/feature', {
|
|
184
|
+
at: { baseSha: pinned.baseSha, headSha: 'origin/alice/feature' },
|
|
185
|
+
}),
|
|
186
|
+
).rejects.toThrow(/invalid commit sha/);
|
|
187
|
+
});
|
|
188
|
+
|
|
133
189
|
/**
|
|
134
190
|
* roles.yaml can never change through a merge — `preserveBaseRolesYaml`
|
|
135
191
|
* restores the base copy onto the source before every merge — so the review
|