@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.
Files changed (158) hide show
  1. package/THIRD-PARTY-NOTICES.md +5 -3
  2. package/dist/core/create-core-server.d.ts.map +1 -1
  3. package/dist/core/create-core-server.js +8 -1
  4. package/dist/core/create-core-server.js.map +1 -1
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js.map +1 -1
  7. package/dist/modules/access/access-control.service.d.ts +58 -2
  8. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  9. package/dist/modules/access/access-control.service.js +174 -33
  10. package/dist/modules/access/access-control.service.js.map +1 -1
  11. package/dist/modules/access/admin-locked-commit.d.ts.map +1 -1
  12. package/dist/modules/access/admin-locked-commit.js +1 -0
  13. package/dist/modules/access/admin-locked-commit.js.map +1 -1
  14. package/dist/modules/access/synced-groups-committer.js +1 -1
  15. package/dist/modules/access/synced-groups-committer.js.map +1 -1
  16. package/dist/modules/access-model/access-errors.d.ts +11 -0
  17. package/dist/modules/access-model/access-errors.d.ts.map +1 -1
  18. package/dist/modules/access-model/access-errors.js +14 -0
  19. package/dist/modules/access-model/access-errors.js.map +1 -1
  20. package/dist/modules/access-model/access-grammar.d.ts +24 -8
  21. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  22. package/dist/modules/access-model/access-grammar.js +64 -3
  23. package/dist/modules/access-model/access-grammar.js.map +1 -1
  24. package/dist/modules/declared-variables/declared-variables.routes.d.ts +42 -0
  25. package/dist/modules/declared-variables/declared-variables.routes.d.ts.map +1 -0
  26. package/dist/modules/declared-variables/declared-variables.routes.js +135 -0
  27. package/dist/modules/declared-variables/declared-variables.routes.js.map +1 -0
  28. package/dist/modules/declared-variables/index.d.ts +2 -0
  29. package/dist/modules/declared-variables/index.d.ts.map +1 -0
  30. package/dist/modules/declared-variables/index.js +2 -0
  31. package/dist/modules/declared-variables/index.js.map +1 -0
  32. package/dist/modules/diff/diff.routes.d.ts +1 -1
  33. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  34. package/dist/modules/diff/diff.routes.js +3 -3
  35. package/dist/modules/diff/diff.routes.js.map +1 -1
  36. package/dist/modules/kb-fs/locking-filesystem.d.ts +16 -0
  37. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  38. package/dist/modules/kb-fs/locking-filesystem.js +20 -0
  39. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  40. package/dist/modules/kb-fs/repo-path.d.ts +32 -0
  41. package/dist/modules/kb-fs/repo-path.d.ts.map +1 -0
  42. package/dist/modules/kb-fs/repo-path.js +54 -0
  43. package/dist/modules/kb-fs/repo-path.js.map +1 -0
  44. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  45. package/dist/modules/secrets-vault/db-secrets-vault.service.js +60 -18
  46. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  47. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +20 -0
  48. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -1
  49. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +121 -47
  50. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -1
  51. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  52. package/dist/modules/secrets-vault/secrets-vault.routes.js +20 -2
  53. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  54. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -1
  55. package/dist/modules/tool-helpers/tool-context.js +1 -0
  56. package/dist/modules/tool-helpers/tool-context.js.map +1 -1
  57. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  58. package/dist/modules/tool-manuals/mcp-json-discovery.js +45 -12
  59. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  60. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  61. package/dist/modules/tool-manuals/mcp-server-edit.service.js +2 -1
  62. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  63. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +40 -8
  64. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  65. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +38 -14
  66. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  67. package/dist/modules/tool-manuals/tool-manuals.service.js +164 -55
  68. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  69. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  70. package/dist/modules/tool-manuals/tool-manuals.tools.js +10 -5
  71. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  72. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts +55 -0
  73. package/dist/modules/tool-manuals/utcp-cli-parse-only.d.ts.map +1 -0
  74. package/dist/modules/tool-manuals/utcp-cli-parse-only.js +76 -0
  75. package/dist/modules/tool-manuals/utcp-cli-parse-only.js.map +1 -0
  76. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +3 -1
  77. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  78. package/dist/modules/workflow/agent-tools/workflow.tools.js +19 -2
  79. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  80. package/dist/modules/workflow/git/git.service.d.ts +32 -1
  81. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  82. package/dist/modules/workflow/git/git.service.js +70 -5
  83. package/dist/modules/workflow/git/git.service.js.map +1 -1
  84. package/dist/modules/workflow/git/pull-request.service.d.ts +3 -3
  85. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  86. package/dist/modules/workflow/git/pull-request.service.js +20 -2
  87. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  88. package/dist/modules/workflow/pending-commits.worker.d.ts +8 -0
  89. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
  90. package/dist/modules/workflow/pending-commits.worker.js +74 -16
  91. package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
  92. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  93. package/dist/modules/workflow/review-workflow/review-workflow.service.js +7 -0
  94. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  95. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  96. package/dist/modules/workflow/workflow.routes.js +12 -0
  97. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  98. package/dist/modules/workflow/workflow.service.d.ts +1 -0
  99. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  100. package/dist/modules/workflow/workflow.service.js +4 -0
  101. package/dist/modules/workflow/workflow.service.js.map +1 -1
  102. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  103. package/dist/modules/workspace/workspace.tools.js +31 -15
  104. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  105. package/kb-template/AGENTS.md +52 -8
  106. package/package.json +4 -3
  107. package/src/core/create-core-server.ts +13 -1
  108. package/src/core/create-core-services.ts +1 -0
  109. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +260 -0
  110. package/src/modules/access/__tests__/access-groups.test.ts +28 -0
  111. package/src/modules/access/access-control.service.ts +198 -37
  112. package/src/modules/access/admin-locked-commit.ts +1 -0
  113. package/src/modules/access/synced-groups-committer.ts +1 -1
  114. package/src/modules/access-model/__tests__/access-grammar.test.ts +101 -1
  115. package/src/modules/access-model/access-errors.ts +19 -0
  116. package/src/modules/access-model/access-grammar.ts +67 -3
  117. package/src/modules/declared-variables/__tests__/declared-variables.route.test.ts +166 -0
  118. package/src/modules/declared-variables/declared-variables.routes.ts +151 -0
  119. package/src/modules/declared-variables/index.ts +1 -0
  120. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +4 -4
  121. package/src/modules/diff/diff.routes.ts +3 -2
  122. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +306 -131
  123. package/src/modules/kb-fs/__tests__/repo-path.test.ts +106 -0
  124. package/src/modules/kb-fs/locking-filesystem.ts +30 -0
  125. package/src/modules/kb-fs/repo-path.ts +56 -0
  126. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +104 -0
  127. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +52 -0
  128. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +48 -1
  129. package/src/modules/secrets-vault/db-secrets-vault.service.ts +73 -22
  130. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +141 -50
  131. package/src/modules/secrets-vault/secrets-vault.routes.ts +600 -582
  132. package/src/modules/tool-helpers/tool-context.ts +1 -0
  133. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +38 -0
  134. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +2 -0
  135. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +243 -0
  136. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +95 -0
  137. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +17 -1
  138. package/src/modules/tool-manuals/mcp-json-discovery.ts +39 -15
  139. package/src/modules/tool-manuals/mcp-server-edit.service.ts +2 -1
  140. package/src/modules/tool-manuals/tool-manuals.contract.ts +40 -9
  141. package/src/modules/tool-manuals/tool-manuals.service.ts +156 -28
  142. package/src/modules/tool-manuals/tool-manuals.tools.ts +10 -5
  143. package/src/modules/tool-manuals/utcp-cli-parse-only.ts +76 -0
  144. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +46 -0
  145. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +60 -1
  146. package/src/modules/workflow/agent-tools/workflow.tools.ts +18 -1
  147. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +56 -0
  148. package/src/modules/workflow/git/__tests__/git.service.commitFile.strayPath.test.ts +162 -0
  149. package/src/modules/workflow/git/__tests__/pull-request.service.getPrDetail.test.ts +114 -0
  150. package/src/modules/workflow/git/git.service.ts +73 -6
  151. package/src/modules/workflow/git/pull-request.service.ts +22 -4
  152. package/src/modules/workflow/pending-commits.worker.ts +80 -18
  153. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +60 -0
  154. package/src/modules/workflow/review-workflow/review-workflow.service.ts +5 -0
  155. package/src/modules/workflow/workflow.routes.ts +12 -0
  156. package/src/modules/workflow/workflow.service.ts +5 -1
  157. package/src/modules/workspace/__tests__/workspace.tools.test.ts +45 -0
  158. 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
- manualName: string,
108
- mcpUrl: string,
109
- ): Promise<
110
- | { status: 'open' }
111
- | {
112
- status: 'oauth';
113
- provider: { authorizationUrl: string; tokenUrl: string; clientId: string; scopes?: string[] };
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 eligible = manuals.filter((m) => {
430
- if (m.type !== 'mcp' || !m.url) return false;
431
- // Local-only servers aren't probeable from here; templated URLs aren't
432
- // resolvable without a caller. Both keep their file-declared behavior.
433
- if (m.remote === false || m.url.includes('${')) return false;
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
- const hasOAuthVar = (m.variables ?? []).some((v) => v.oauth != null);
437
- return !hasAuthHeader && !hasOAuthVar;
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
- eligible.map(async (m) => {
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
- return dedupeById(parsed, (m) => m.name, (m, id) =>
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 id "${id}" is already used by another ` +
562
- '`.tool` — give it a unique `id` (the id is the secret-variable namespace and must be unique).',
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 provider does not support automatic registration, so a writer ' +
73
- 'must declare the OAuth provider in the `.tool` file and paste its client secret into the tool ' +
74
- 'editor). Per variable: whether the shared (admin) value is set, whether the CURRENT user has ' +
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 (their own credentials come from ' +
185
- 'that process\'s environment). Otherwise each entry gives the tool’s name and its `.tool` file ' +
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: { path: { type: 'string', minLength: 1, description: 'Workspace-relative path (as write_file expects).' } },
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