@bevel-software/platform-core-backend 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/agent-guide/access-control.md +146 -300
  2. package/agent-guide/conventions.md +23 -25
  3. package/agent-guide/directory-structure.md +72 -120
  4. package/agent-guide/drafts-and-change-requests.md +92 -0
  5. package/agent-guide/for-git-users.md +47 -0
  6. package/agent-guide/glossary.md +36 -0
  7. package/agent-guide/how-agents-find-things.md +49 -0
  8. package/agent-guide/html-views.md +16 -11
  9. package/agent-guide/new-knowledge-base.md +9 -8
  10. package/agent-guide/skills.md +67 -42
  11. package/agent-guide/start-here.md +65 -0
  12. package/agent-guide/tool-manuals.md +132 -154
  13. package/agent-guide/tools.md +67 -0
  14. package/agent-guide/where-a-new-file-goes.md +45 -28
  15. package/dist/core/create-core-server.js +1 -1
  16. package/dist/core/create-core-server.js.map +1 -1
  17. package/dist/core/create-core-services.d.ts +6 -0
  18. package/dist/core/create-core-services.d.ts.map +1 -1
  19. package/dist/core/create-core-services.js +7 -4
  20. package/dist/core/create-core-services.js.map +1 -1
  21. package/dist/modules/access/access-control.interface.d.ts +22 -0
  22. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  23. package/dist/modules/access/access-control.service.d.ts +3 -1
  24. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  25. package/dist/modules/access/access-control.service.js +75 -0
  26. package/dist/modules/access/access-control.service.js.map +1 -1
  27. package/dist/modules/access/access.routes.d.ts.map +1 -1
  28. package/dist/modules/access/access.routes.js +23 -4
  29. package/dist/modules/access/access.routes.js.map +1 -1
  30. package/dist/modules/access/index.d.ts +1 -1
  31. package/dist/modules/access/index.d.ts.map +1 -1
  32. package/dist/modules/agent-guide/agent-guide.d.ts +47 -7
  33. package/dist/modules/agent-guide/agent-guide.d.ts.map +1 -1
  34. package/dist/modules/agent-guide/agent-guide.js +103 -13
  35. package/dist/modules/agent-guide/agent-guide.js.map +1 -1
  36. package/dist/modules/agent-guide/agent-guide.tools.d.ts +6 -2
  37. package/dist/modules/agent-guide/agent-guide.tools.d.ts.map +1 -1
  38. package/dist/modules/agent-guide/agent-guide.tools.js +45 -31
  39. package/dist/modules/agent-guide/agent-guide.tools.js.map +1 -1
  40. package/dist/modules/agent-guide/guide-budget.d.ts +48 -0
  41. package/dist/modules/agent-guide/guide-budget.d.ts.map +1 -0
  42. package/dist/modules/agent-guide/guide-budget.js +67 -0
  43. package/dist/modules/agent-guide/guide-budget.js.map +1 -0
  44. package/dist/modules/agent-guide/index.d.ts +2 -1
  45. package/dist/modules/agent-guide/index.d.ts.map +1 -1
  46. package/dist/modules/agent-guide/index.js +2 -1
  47. package/dist/modules/agent-guide/index.js.map +1 -1
  48. package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
  49. package/dist/modules/agent-instructions/compose.js +4 -0
  50. package/dist/modules/agent-instructions/compose.js.map +1 -1
  51. package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -1
  52. package/dist/modules/agent-instructions/shared-file-rules.js +10 -8
  53. package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -1
  54. package/dist/modules/code-mode/code-mode.tool.js +2 -2
  55. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  56. package/dist/modules/connection-probe/connection-probe.service.d.ts +0 -12
  57. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -1
  58. package/dist/modules/connection-probe/connection-probe.service.js +1 -38
  59. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -1
  60. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  61. package/dist/modules/mcp/mcp.service.js +6 -35
  62. package/dist/modules/mcp/mcp.service.js.map +1 -1
  63. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +11 -1
  64. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -1
  65. package/dist/modules/secrets-vault/db-secrets-vault.service.js +159 -24
  66. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  67. package/dist/modules/secrets-vault/index.d.ts +1 -1
  68. package/dist/modules/secrets-vault/index.d.ts.map +1 -1
  69. package/dist/modules/secrets-vault/index.js +1 -1
  70. package/dist/modules/secrets-vault/index.js.map +1 -1
  71. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +53 -11
  72. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -1
  73. package/dist/modules/secrets-vault/secrets-vault.contract.js +27 -0
  74. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -1
  75. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  76. package/dist/modules/secrets-vault/secrets-vault.routes.js +85 -39
  77. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  78. package/dist/modules/settings/oidc-check.d.ts.map +1 -1
  79. package/dist/modules/settings/oidc-check.js +5 -17
  80. package/dist/modules/settings/oidc-check.js.map +1 -1
  81. package/dist/modules/skills/skills.tools.js +1 -1
  82. package/dist/modules/skills/skills.tools.js.map +1 -1
  83. package/dist/modules/tool-helpers/tool-def.js +1 -1
  84. package/dist/modules/tool-helpers/tool-def.js.map +1 -1
  85. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +7 -1
  86. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  87. package/dist/modules/tool-manuals/mcp-json-discovery.js +69 -38
  88. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +27 -0
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +12 -8
  92. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  93. package/dist/modules/tool-manuals/tool-manuals.service.js +115 -44
  94. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  95. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +8 -2
  96. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.tools.js +31 -10
  98. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  99. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  100. package/dist/modules/workflow/agent-tools/workflow.tools.js +22 -18
  101. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  102. package/dist/modules/workspace/agent-access.gate.d.ts +1 -1
  103. package/dist/modules/workspace/agent-access.gate.d.ts.map +1 -1
  104. package/dist/modules/workspace/agent-access.gate.js +1 -1
  105. package/dist/modules/workspace/agent-access.gate.js.map +1 -1
  106. package/dist/modules/workspace/workspace.tools.d.ts +3 -2
  107. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  108. package/dist/modules/workspace/workspace.tools.js +71 -21
  109. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  110. package/dist/shared/oauth-token-error.d.ts +23 -0
  111. package/dist/shared/oauth-token-error.d.ts.map +1 -0
  112. package/dist/shared/oauth-token-error.js +47 -0
  113. package/dist/shared/oauth-token-error.js.map +1 -0
  114. package/dist/shared/read-capped.d.ts +19 -0
  115. package/dist/shared/read-capped.d.ts.map +1 -0
  116. package/dist/shared/read-capped.js +45 -0
  117. package/dist/shared/read-capped.js.map +1 -0
  118. package/package.json +4 -3
  119. package/src/__tests__/hexis-catalog.ts +144 -0
  120. package/src/__tests__/real-disk.ts +21 -0
  121. package/src/__tests__/tokens.ts +19 -0
  122. package/src/__tests__/tool-manuals.ts +32 -0
  123. package/src/__tests__/tool-wiring.ts +25 -0
  124. package/src/core/__tests__/oauth-sign-in-real-file.test.ts +179 -0
  125. package/src/core/create-core-server.ts +1 -1
  126. package/src/core/create-core-services.ts +13 -3
  127. package/src/modules/access/__tests__/access.routes.prospective.test.ts +52 -12
  128. package/src/modules/access/__tests__/holder-changes.test.ts +127 -0
  129. package/src/modules/access/access-control.interface.ts +26 -0
  130. package/src/modules/access/access-control.service.ts +92 -0
  131. package/src/modules/access/access.routes.ts +23 -4
  132. package/src/modules/access/index.ts +1 -1
  133. package/src/modules/agent-guide/__tests__/agent-guide.test.ts +79 -25
  134. package/src/modules/agent-guide/__tests__/agent-guide.tools.test.ts +38 -11
  135. package/src/modules/agent-guide/__tests__/guide-budget.test.ts +95 -0
  136. package/src/modules/agent-guide/__tests__/guide-eval.test.ts +182 -0
  137. package/src/modules/agent-guide/__tests__/guide-matches-code.test.ts +263 -0
  138. package/src/modules/agent-guide/__tests__/tool-surface.json +514 -0
  139. package/src/modules/agent-guide/__tests__/tool-surface.test.ts +75 -0
  140. package/src/modules/agent-guide/agent-guide.tools.ts +49 -31
  141. package/src/modules/agent-guide/agent-guide.ts +120 -13
  142. package/src/modules/agent-guide/guide-budget.ts +73 -0
  143. package/src/modules/agent-guide/index.ts +12 -0
  144. package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +4 -4
  145. package/src/modules/agent-instructions/compose.ts +4 -0
  146. package/src/modules/agent-instructions/shared-file-rules.ts +10 -8
  147. package/src/modules/code-mode/code-mode.tool.ts +2 -2
  148. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +5 -24
  149. package/src/modules/connection-probe/connection-probe.service.ts +1 -34
  150. package/src/modules/mcp/mcp.service.ts +5 -35
  151. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +430 -3
  152. package/src/modules/secrets-vault/__tests__/oauth-live-declaration.route.test.ts +218 -0
  153. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +39 -6
  154. package/src/modules/secrets-vault/db-secrets-vault.service.ts +170 -27
  155. package/src/modules/secrets-vault/index.ts +1 -1
  156. package/src/modules/secrets-vault/secrets-vault.contract.ts +61 -11
  157. package/src/modules/secrets-vault/secrets-vault.routes.ts +91 -39
  158. package/src/modules/settings/oidc-check.ts +5 -18
  159. package/src/modules/skills/skills.tools.ts +1 -1
  160. package/src/modules/tool-helpers/__tests__/agent-roles-write.test.ts +19 -11
  161. package/src/modules/tool-helpers/__tests__/route-tool-schemas.catalog.test.ts +38 -0
  162. package/src/modules/tool-helpers/tool-def.ts +1 -1
  163. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +197 -11
  164. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +96 -5
  165. package/src/modules/tool-manuals/mcp-json-discovery.ts +73 -34
  166. package/src/modules/tool-manuals/tool-manuals.contract.ts +22 -0
  167. package/src/modules/tool-manuals/tool-manuals.service.ts +138 -46
  168. package/src/modules/tool-manuals/tool-manuals.tools.ts +53 -14
  169. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +6 -113
  170. package/src/modules/workflow/agent-tools/workflow.tools.ts +22 -18
  171. package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +8 -10
  172. package/src/modules/workspace/__tests__/workspace.tools.test.ts +156 -49
  173. package/src/modules/workspace/agent-access.gate.ts +1 -1
  174. package/src/modules/workspace/workspace.tools.ts +74 -21
  175. package/src/shared/__tests__/read-capped.test.ts +68 -0
  176. package/src/shared/oauth-token-error.ts +46 -0
  177. package/src/shared/read-capped.ts +40 -0
  178. package/agent-guide/finding-things.md +0 -7
  179. package/agent-guide/introduction.md +0 -27
@@ -74,6 +74,13 @@ export interface SecretConfigStatus {
74
74
  grantedScopes?: string;
75
75
  }
76
76
 
77
+ /**
78
+ * A declared sign-in's public provider configuration, as the tool file reads it:
79
+ * everything but the secret (only the owner's shared row holds it) and the
80
+ * public-client flag (a declared client is confidential by definition).
81
+ */
82
+ export type DeclaredOAuthProvider = Omit<OAuthProviderConfig, 'clientSecret' | 'publicClient'>;
83
+
77
84
  /**
78
85
  * How a secret is provisioned — mirrors `ToolVariableScope` in the tool-manuals
79
86
  * contract, redeclared here to keep the vault free of a tool-manuals import.
@@ -186,8 +193,10 @@ export interface ISecretsVaultService {
186
193
  /**
187
194
  * Set the confidential client secret + provider config for a tool's OAuth-backed
188
195
  * variable, on the SHARED row (`user_id NULL`) for `key`. The provider meta is
189
- * stored ALONGSIDE the secret so a later `.tool` edit can't redirect it. Owner-
190
- * gated by the route (write access on the `.tool`).
196
+ * stored ALONGSIDE the secret as a record of the save; a sign-in follows the
197
+ * declaration as the tool file reads when it starts (`beginToolOAuthByKey`'s
198
+ * `provider`), not this copy. Owner-gated by the route (write access on the
199
+ * `.tool`).
191
200
  */
192
201
  putSharedOAuthClientSecret(input: {
193
202
  key: string;
@@ -212,16 +221,26 @@ export interface ISecretsVaultService {
212
221
  */
213
222
  getSharedOAuthProvider(key: string): Promise<OAuthProviderConfig | null>;
214
223
  /**
215
- * Provision (if needed) the caller's per-user oauth row for `key` from the SHARED
216
- * row's stored provider meta + client secret, then build the consent URL. 409 if
217
- * the owner hasn't set the client secret. Returns the provisioned row's id (to
218
- * sign into the callback state) and the consent url.
224
+ * Provision (if needed) the caller's per-user oauth row for `key` and build the
225
+ * consent URL. 409 if the owner hasn't set the client secret. Returns the
226
+ * provisioned row's id (to sign into the callback state) and the consent url.
219
227
  *
220
- * `scopes`, when given, are the permissions to REQUEST on the consent screen —
221
- * supplied by the caller from the live tool file so a scope added there takes
222
- * effect without the owner re-entering the secret. Only the requested scopes
223
- * follow the tool file; the client id, addresses, and secret are still read from
224
- * the owner-set shared row. Omitted → the shared row's stored scopes are used.
228
+ * Where the provider configuration comes from depends on who registered the
229
+ * client, and each kind of sign-in has exactly one source:
230
+ * - `provider` given — a DECLARED sign-in: the client id, endpoints, PKCE,
231
+ * resource and authorize params are the declaration as the tool file reads
232
+ * now, handed over by the caller. They are validated as the client-secret
233
+ * save validates them (https, no private or local address) before anything
234
+ * is written or built, and they are what the caller's row records, so the
235
+ * token exchange and later refreshes of this grant use the configuration
236
+ * the consent was given for. The shared row contributes the client secret
237
+ * only, and must hold one.
238
+ * - `provider` absent — a client the platform registered itself (dynamic
239
+ * client registration): nothing in a file describes it, so the shared
240
+ * row's stored configuration is used.
241
+ *
242
+ * `scopes`, when given, are the permissions to REQUEST on the consent screen
243
+ * (the live tool file's); omitted → the provider configuration's own scopes.
225
244
  */
226
245
  beginToolOAuthByKey(input: {
227
246
  userId: string;
@@ -229,6 +248,7 @@ export interface ISecretsVaultService {
229
248
  redirectUri: string;
230
249
  state: string;
231
250
  scopes?: string[];
251
+ provider?: DeclaredOAuthProvider;
232
252
  }): Promise<{ id: string; url: string }>;
233
253
 
234
254
  /**
@@ -342,6 +362,36 @@ export function scopesCovered(required: string[] | undefined, granted: string |
342
362
  return missingScopes(required, granted).length === 0;
343
363
  }
344
364
 
365
+ /** Why a caller's own value for a per-user variable is not ready yet; see {@link userSecretGap}. */
366
+ export type UserSecretGap = 'missing' | 'wrong-kind' | 'unauthorized' | 'under-scoped';
367
+
368
+ /**
369
+ * Whether the CALLER's own value for a per-user variable is ready, and if not,
370
+ * why: the one rule the MCP proxy hides a tool by and `list_tool_setup` reports
371
+ * from, so the two cannot disagree. `null` when it is ready.
372
+ *
373
+ * - `missing`: no row at all, so it needs a value or a sign-in.
374
+ * - `wrong-kind`: a row of the OTHER kind than the manual now declares. A
375
+ * `.tool` edited from an OAuth sign-in to a plain key (or back) leaves the
376
+ * old row behind, and serving a token where a key is declared fails
377
+ * opaquely at the provider. The credential has to be set up again.
378
+ * - `unauthorized`: an OAuth row with no token yet: registered, sign-in not
379
+ * completed. Fail closed: anything but a confirmed `true` counts.
380
+ * - `under-scoped`: a token granted fewer scopes than the tool now declares,
381
+ * so the call would fail at the provider; it needs signing in again.
382
+ */
383
+ export function userSecretGap(
384
+ declared: { oauth: boolean; oauthScopes?: string[] },
385
+ status: Pick<SecretConfigStatus, 'userConfigured' | 'userAuthorized' | 'grantedScopes'> & { userKind?: SecretKind | null } | undefined,
386
+ ): UserSecretGap | null {
387
+ if (!status?.userConfigured) return 'missing';
388
+ const declaredKind: SecretKind = declared.oauth ? 'oauth' : 'static';
389
+ if (status.userKind && status.userKind !== declaredKind) return 'wrong-kind';
390
+ if (declared.oauth && status.userAuthorized !== true) return 'unauthorized';
391
+ if (declared.oauth && !scopesCovered(declared.oauthScopes, status.grantedScopes)) return 'under-scoped';
392
+ return null;
393
+ }
394
+
345
395
  /**
346
396
  * Is the stored row the KIND the manual now declares — i.e. would the thing the
347
397
  * UI is about to offer actually work?
@@ -10,6 +10,7 @@ import {
10
10
  scopesCovered,
11
11
  missingScopes,
12
12
  configuredAs,
13
+ type DeclaredOAuthProvider,
13
14
  type ISecretsVaultService,
14
15
  } from './secrets-vault.contract.js';
15
16
  import type { IToolManualService, ToolManualSummary, ToolVariable } from '../tool-manuals/index.js';
@@ -53,6 +54,38 @@ function redirectUriFor(publicBackendUrl: string): string {
53
54
  return `${publicBackendUrl}/api/secrets/oauth/callback`;
54
55
  }
55
56
 
57
+ /**
58
+ * What of a declared sign-in is its provider configuration — ONE reading, for
59
+ * the client-secret save (which stores it as a record) and the sign-in start
60
+ * (which runs from it). `null` while the declaration has no endpoints: it named
61
+ * only its client id and the server's metadata could not complete it.
62
+ */
63
+ function declaredProvider(variable: ToolVariable): DeclaredOAuthProvider | null {
64
+ const oauth = variable.oauth;
65
+ if (!oauth?.authorizationUrl || !oauth.tokenUrl) return null;
66
+ return {
67
+ authorizationUrl: oauth.authorizationUrl,
68
+ tokenUrl: oauth.tokenUrl,
69
+ clientId: oauth.clientId,
70
+ scopes: oauth.scopes,
71
+ // Static authorize params (e.g. Google's `access_type=offline`) so the
72
+ // provider returns a refresh token.
73
+ authParams: oauth.authParams,
74
+ // PKCE unless the declaration opted out; the resource indicator when the
75
+ // server (or the declaration) names one.
76
+ pkce: oauth.pkce !== false,
77
+ resource: oauth.resource,
78
+ };
79
+ }
80
+
81
+ /** Why a declared sign-in has no endpoints — the discovery's own reason when it gave one. */
82
+ function endpointsUnknown(manual: ToolManualSummary): string {
83
+ const why = manual.setup?.reason;
84
+ return why
85
+ ? `The sign-in endpoints for this server aren't known yet: ${why}.`
86
+ : "The sign-in endpoints for this server aren't known yet — declare `authorizationUrl` and `tokenUrl` on the sign-in variable.";
87
+ }
88
+
56
89
  /**
57
90
  * Is this a SAME-ORIGIN path we're willing to bounce the browser back to after a
58
91
  * sign-in? The callback builds `${publicFrontendUrl}${dest}#…`, so anything that
@@ -461,8 +494,8 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
461
494
 
462
495
  // Set the confidential client secret for a tool's OAuth-backed variable — the
463
496
  // ONLY place the secret is provided. Requires WRITE on the `.tool`. The provider
464
- // config comes from the variable's own `oauth` declaration (stored WITH the
465
- // secret so a later `.tool` edit can't redirect it).
497
+ // config of the variable's own `oauth` declaration is stored with the secret as
498
+ // a record of the save; a sign-in start reads the declaration afresh.
466
499
  router.put('/secrets/tools/:slug/vars/:var/oauth/admin', async (req, res) => {
467
500
  const userId = req.userId;
468
501
  const email = req.userEmail;
@@ -476,38 +509,17 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
476
509
  if (!(await accessControl.canWrite(defaultWs(), email, found.manual.path))) {
477
510
  return void res.status(403).json({ error: 'You need write access to this tool to set its client secret.' });
478
511
  }
479
- const { authorizationUrl, tokenUrl } = found.variable.oauth;
480
512
  // A declaration that names only its client id is completed from the
481
- // server's OAuth metadata at scan time. When that didn't happen, pinning
482
- // the secret to a provider with no endpoints would leave every sign-in
483
- // failing later with a far less specific error than the catalog's own.
484
- if (!authorizationUrl || !tokenUrl) {
485
- const why = found.manual.setup?.reason;
486
- return void res.status(422).json({
487
- error: why
488
- ? `The sign-in endpoints for this server aren't known yet: ${why}.`
489
- : "The sign-in endpoints for this server aren't known yet — declare `authorizationUrl` and `tokenUrl` on the sign-in variable.",
490
- });
491
- }
513
+ // server's OAuth metadata. When that didn't happen, storing the secret
514
+ // with a provider that has no endpoints would leave every sign-in failing
515
+ // later with a far less specific error than the catalog's own.
516
+ const provider = declaredProvider(found.variable);
517
+ if (!provider) return void res.status(422).json({ error: endpointsUnknown(found.manual) });
492
518
  const clientSecret = (req.body ?? {}).clientSecret;
493
519
  await secretsVault.putSharedOAuthClientSecret({
494
520
  key: varKey(found.manual.name, found.variable.name),
495
521
  clientSecret,
496
- provider: {
497
- authorizationUrl,
498
- tokenUrl,
499
- clientId: found.variable.oauth.clientId,
500
- scopes: found.variable.oauth.scopes,
501
- // Static authorize params (e.g. Google's `access_type=offline`) so the
502
- // provider returns a refresh token — stored with the secret so a later
503
- // `.tool` edit can't redirect the flow.
504
- authParams: found.variable.oauth.authParams,
505
- // PKCE unless the declaration opted out; the resource indicator when
506
- // the server (or the declaration) names one. Both pinned here for the
507
- // same reason as the rest: the flow runs from the stored row.
508
- pkce: found.variable.oauth.pkce !== false,
509
- resource: found.variable.oauth.resource,
510
- },
522
+ provider,
511
523
  });
512
524
  res.status(201).json({ ok: true });
513
525
  } catch (err) {
@@ -525,12 +537,37 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
525
537
  const email = req.userEmail;
526
538
  if (!userId || !email) return void res.status(401).json({ error: 'Not authenticated' });
527
539
  try {
528
- const found = await findManualVar(email, req.params.slug, req.params.var);
529
- if (!found) return void res.status(404).json({ error: 'Tool or variable not found' });
530
- if (!found.variable.oauth) {
531
- return void res.status(422).json({ error: 'This variable is not an OAuth sign-in.' });
540
+ // Two sources, one per kind of sign-in. A DECLARED one follows its tool
541
+ // file as it reads now — never the catalog's scan, which can be a refresh
542
+ // behind the file, and never the copy the client-secret save stored. A
543
+ // client the platform registered itself (oauth-auto) is described by no
544
+ // file: the catalog's discovery surfaced it, and its stored row is the
545
+ // only description of it there is.
546
+ const [live, catalog] = await Promise.all([
547
+ toolManualService.liveDeclaration(email, req.params.slug, req.params.var),
548
+ findManualVar(email, req.params.slug, req.params.var),
549
+ ]);
550
+ // The file is there but its declaration is refused as it reads now (an
551
+ // http:// or private endpoint, say): the parser's reason is the answer,
552
+ // and neither a catalog still holding the earlier version nor the stored
553
+ // row stands in for it. Nothing is sent anywhere.
554
+ if (live?.refused) return void res.status(422).json({ error: live.refused });
555
+ const manual = live?.manual ?? catalog?.manual;
556
+ if (!manual || (!live?.variable && !catalog)) {
557
+ return void res.status(404).json({ error: 'Tool or variable not found' });
558
+ }
559
+ const key = varKey(manual.name, req.params.var);
560
+ const declared = live?.variable?.oauth ? live.variable : null;
561
+ const registered =
562
+ !declared && catalog?.manual.setup?.kind === 'oauth-auto' && catalog.variable.oauth ? catalog.variable : null;
563
+ const signIn = declared ?? registered;
564
+ // No sign-in in the file and none the platform registered: whatever the
565
+ // shared row still holds is not used.
566
+ if (!signIn?.oauth) {
567
+ throw new SecretOAuthError("This tool's owner hasn't finished setting this up");
532
568
  }
533
- const key = varKey(found.manual.name, found.variable.name);
569
+ const provider = declared ? declaredProvider(declared) : undefined;
570
+ if (provider === null) return void res.status(422).json({ error: endpointsUnknown(manual) });
534
571
  // Provision the caller's oauth row (from the owner-set secret) FIRST — its id
535
572
  // is what the un-authed callback keys on. Pass an empty state placeholder; we
536
573
  // sign the real state below with the returned id and swap it into the URL, so
@@ -540,10 +577,10 @@ export function createSecretsVaultRoutes(deps: SecretsVaultRoutesDeps): express.
540
577
  key,
541
578
  redirectUri: redirectUriFor(deps.publicBackendUrl),
542
579
  state: '',
543
- // Request the permissions the tool file declares RIGHT NOW (not the frozen
544
- // copy on the owner-set row), so adding a scope to the `.tool` takes effect
545
- // on the next sign-in. The secret/clientId/addresses stay owner-pinned.
546
- scopes: found.variable.oauth.scopes,
580
+ // Request the permissions the tool file declares RIGHT NOW, so adding a
581
+ // scope takes effect on the next sign-in.
582
+ scopes: signIn.oauth.scopes,
583
+ provider,
547
584
  });
548
585
  // The return path is signed INTO the state (HMAC-SHA256) rather than kept
549
586
  // in a query param or a cookie: the callback arrives un-authenticated from
@@ -657,7 +694,22 @@ export function createSecretsVaultPublicRoutes(deps: SecretsVaultRoutesDeps): ex
657
694
  back(`authorized=${encodeURIComponent(state.i)}`, dest);
658
695
  } catch (err) {
659
696
  log.error('oauth callback failed:', { err });
660
- back(`error=${encodeURIComponent('Authorization failed. Check the provider configuration and try again.')}`, dest);
697
+ // A provider's REFUSAL (the vault's error carrying the provider's HTTP
698
+ // status) names the provider's error code and Hexis's own sentence for
699
+ // it — the one thing the person on the Connect page (or the agent they
700
+ // paste it to) can act on: a client the provider does not accept, a
701
+ // refused authorization code. Nothing of the provider's text is in it.
702
+ // Every other failure stays the generic line: a transport error quotes
703
+ // what fetch said, which is not a provider's word.
704
+ const refusal = err instanceof SecretOAuthError && err.providerStatus !== undefined ? err.message : null;
705
+ back(
706
+ `error=${encodeURIComponent(
707
+ refusal
708
+ ? `Authorization failed. ${refusal}`
709
+ : 'Authorization failed. Check the provider configuration and try again.',
710
+ )}`,
711
+ dest,
712
+ );
661
713
  }
662
714
  });
663
715
 
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { isStandardTokenErrorCode } from '../../shared/oauth-token-error.js';
2
3
 
3
4
  /** The single sign-on configuration a deployment would sign people in with. */
4
5
  export interface OidcConfiguration {
@@ -55,21 +56,6 @@ const REQUEST_TIMEOUT_MS = 10_000;
55
56
  */
56
57
  const CODE_REJECTIONS: readonly string[] = ['invalid_grant'];
57
58
 
58
- /**
59
- * The other error codes RFC 6749 §5.2 (and OIDC Core §3.1.2.6) define — the
60
- * only ones echoed back in an "unverified" message. Anything else in `error`
61
- * is text of the provider's choosing, and could be the secret reflected back.
62
- */
63
- const ECHOED_ERRORS: readonly string[] = [
64
- 'invalid_request',
65
- 'unauthorized_client',
66
- 'unsupported_grant_type',
67
- 'invalid_scope',
68
- 'access_denied',
69
- 'server_error',
70
- 'temporarily_unavailable',
71
- ];
72
-
73
59
  /** The issuer as the provider appends `/.well-known/...` to it: no trailing slash. */
74
60
  export function normalizeIssuerUrl(issuerUrl: string): string {
75
61
  return issuerUrl.trim().replace(/\/+$/, '');
@@ -219,9 +205,10 @@ export async function checkOidcConfiguration(
219
205
  };
220
206
  }
221
207
  if (CODE_REJECTIONS.includes(errorCode)) return { outcome: 'verified' };
222
- // Only a standard error code is echoed — anything else could be text of the
223
- // provider's choosing, the secret included.
224
- const code = ECHOED_ERRORS.includes(errorCode) ? ` (${errorCode})` : ` (HTTP ${status})`;
208
+ // Only a standard error code is echoed (the one list the vault reads a
209
+ // refusal by too) — anything else could be text of the provider's choosing,
210
+ // the secret included.
211
+ const code = isStandardTokenErrorCode(errorCode) ? ` (${errorCode})` : ` (HTTP ${status})`;
225
212
  return {
226
213
  outcome: 'unverified',
227
214
  error: `The application ID and secret could not be verified — the provider gave an answer that does not say whether they are right${code}.`,
@@ -33,7 +33,7 @@ const LIST_SKILLS_INPUTS = {
33
33
  const GET_SKILL_INPUTS = {
34
34
  type: 'object' as const,
35
35
  properties: {
36
- name: { type: 'string' as const, minLength: 1, description: 'Skill name (its folder name, e.g. `rfi`).' },
36
+ name: { type: 'string' as const, minLength: 1, description: 'Skill name, as `list_skills` gives it (its `id`, else its `name`, else its folder name; e.g. `rfi`).' },
37
37
  file: {
38
38
  type: 'string' as const,
39
39
  description:
@@ -4,21 +4,27 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import express from 'express';
6
6
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
- import { ToolRegistry } from '../../tool-registry/tool-registry.js';
8
- import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
9
- import { createToolAuthMiddleware } from '../../tool-auth/tool-auth.middleware.js';
7
+ import { ToolRegistry } from '../../tool-registry/index.js';
8
+ import {
9
+ InternalTokenService,
10
+ RoutineWritePolicyService,
11
+ WorkflowHooks,
12
+ createToolAuthMiddleware,
13
+ loadActiveGroups,
14
+ registerWorkspaceTools,
15
+ } from '../../../__tests__/tool-wiring.js';
10
16
  import { createToolContextResolver } from '../tool-context.js';
11
17
  import { createToolHandlerFactory } from '../tool-handler.js';
12
- import { registerWorkspaceTools } from '../../workspace/workspace.tools.js';
13
- import { RoutineWritePolicyService } from '../../workspace/routine-write-policy.js';
14
- import { WorkflowHooks } from '../../workflow/workflow-hooks.js';
15
- import { ToolDescriptionNotes } from '../../workspace/agent-access.gate.js';
16
- import { SpillStore } from '../../workspace/spill-store.js';
17
- import { DocExtractService, WorkerDocumentExtractor } from '../../workspace/index.js';
18
- import { NEW_ROLE_GUIDANCE } from '../../access-model/roles-yaml-guard.js';
19
- import { loadActiveGroups } from '../../access/access-control.service.js';
18
+ import { DocExtractService, SpillStore, ToolDescriptionNotes, WorkerDocumentExtractor } from '../../workspace/index.js';
20
19
  import { testKbContext } from '../../../__tests__/kb-context.js';
21
20
 
21
+ /**
22
+ * The sentence a refused role creation carries to the agent, as the agent
23
+ * guide states it too: asserted as the words a person reads, not as the
24
+ * module's constant, so a reworded refusal is a visible change here.
25
+ */
26
+ const NEW_ROLE_GUIDANCE = 'add people to existing roles, and use a GROUP for a task- or team-scoped set of people';
27
+
22
28
  /**
23
29
  * The agent's file tools over the production context resolver and a real
24
30
  * workspace on disk: a roles.yaml write that creates a role is refused with a
@@ -71,6 +77,8 @@ async function start(): Promise<string> {
71
77
  canReadBatch: async (_w: string, _u: string, paths: string[]) => new Map(paths.map((p) => [p, true])),
72
78
  // The `after` half of a move's or copy's preview — allowing everything, like the gates above.
73
79
  previewAccessAfterRelocation: async () => ({ read: true, write: true, download: true, owner: true }),
80
+ // Who else loses or gains access in a move: nobody, here.
81
+ prospectiveHolderChanges: async () => ({ lose: [], gain: [] }),
74
82
  } as never;
75
83
  registerWorkspaceTools(registry, router, toolAuth, createToolHandlerFactory(resolve), new SpillStore(path.join(os.tmpdir(), 'bevel-test-spills')), new DocExtractService(docCache, new WorkerDocumentExtractor()), allowAll, testKbContext({ kbDirName: KB }), {
76
84
  // No hook registered: the agent-access gate refuses nothing here.
@@ -0,0 +1,38 @@
1
+ import { compileCheck, exampleArguments } from '@bevel-software/platform-mcp-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { hexisCatalog } from '../../../__tests__/hexis-catalog.js';
4
+ import { routeToolSchemas } from '../route-tool-schemas.js';
5
+
6
+ /**
7
+ * Every tool a route mounts declares two schemas: the one a client sees
8
+ * (`inputs`, with the platform tools' arguments under `body`) and the FLAT one
9
+ * the route itself checks a call against. The `Call:` line at the top of each
10
+ * description is generated from the first; the route refuses by the second.
11
+ * The two must agree, or a call written from the example is refused one
12
+ * layer in, with nothing in the description to say why.
13
+ *
14
+ * Held here, over the whole catalog Hexis registers, because the flat schema
15
+ * is this module's: `routeToolSchemas` is how a mounted route finds its own,
16
+ * and no other module reads it.
17
+ */
18
+ describe('the flat schema a route checks against accepts the call its own example shows', () => {
19
+ it('for every tool Hexis mounts on a route', async () => {
20
+ const { internal } = await hexisCatalog();
21
+ const mounted = internal.filter((tool) => routeToolSchemas(tool.name) !== undefined);
22
+ // The catalog is the real one: the family every scenario names is in it.
23
+ for (const name of ['read_file', 'write_file', 'list_files', 'grep', 'move_file']) {
24
+ expect(mounted.some((t) => t.name === name), name).toBe(true);
25
+ }
26
+ expect(mounted.length).toBeGreaterThan(12);
27
+ for (const tool of mounted) {
28
+ // The client's schema is the flat one in its envelope (`bodyEnvelope`),
29
+ // so the client's example and the route's check meet on the flat schema:
30
+ // an example written from it must pass the check made from it.
31
+ const flat = routeToolSchemas(tool.name)!.flat;
32
+ const check = compileCheck(flat);
33
+ expect(check.checkable, `${tool.name} (flat): ${check.checkable ? '' : check.reason}`).toBe(true);
34
+ if (!check.checkable) throw new Error(check.reason);
35
+ expect(check.check(exampleArguments(flat)), `${tool.name} (flat)`).toEqual([]);
36
+ }
37
+ });
38
+ });
@@ -16,7 +16,7 @@ const toolSerializer = new ToolSerializer();
16
16
  export const BRANCH_INPUT: JsonSchema = {
17
17
  type: 'string',
18
18
  minLength: 1,
19
- description: 'The branch (draft) whose workspace this operates on — pass the branch you are currently working on.',
19
+ description: 'The branch to work on: the default branch (`get_agent_guide` names it) or your draft.',
20
20
  };
21
21
 
22
22
  /**