@dbx-tools/appkit-mastra 0.6.48 → 0.6.50

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/package.json CHANGED
@@ -31,18 +31,18 @@
31
31
  "dependencies": {
32
32
  "@databricks/appkit": "^0.43.0",
33
33
  "@databricks/sdk-experimental": "^0.17.0",
34
- "@dbx-tools/appkit": "0.6.48",
35
- "@dbx-tools/core": "0.6.48",
36
- "@dbx-tools/databricks": "0.6.48",
37
- "@dbx-tools/fs": "0.6.48",
38
- "@dbx-tools/genie": "0.6.48",
39
- "@dbx-tools/model": "0.6.48",
40
- "@dbx-tools/path": "0.6.48",
41
- "@dbx-tools/shared-core": "0.6.48",
42
- "@dbx-tools/shared-fs": "0.6.48",
43
- "@dbx-tools/shared-genie": "0.6.48",
44
- "@dbx-tools/shared-mastra": "0.6.48",
45
- "@dbx-tools/shared-model": "0.6.48",
34
+ "@dbx-tools/appkit": "0.6.50",
35
+ "@dbx-tools/core": "0.6.50",
36
+ "@dbx-tools/databricks": "0.6.50",
37
+ "@dbx-tools/fs": "0.6.50",
38
+ "@dbx-tools/genie": "0.6.50",
39
+ "@dbx-tools/model": "0.6.50",
40
+ "@dbx-tools/path": "0.6.50",
41
+ "@dbx-tools/shared-core": "0.6.50",
42
+ "@dbx-tools/shared-fs": "0.6.50",
43
+ "@dbx-tools/shared-genie": "0.6.50",
44
+ "@dbx-tools/shared-mastra": "0.6.50",
45
+ "@dbx-tools/shared-model": "0.6.50",
46
46
  "@mastra/ai-sdk": "^1.6.0",
47
47
  "@mastra/core": "^1.47.0",
48
48
  "@mastra/express": "^1.4.2",
@@ -71,7 +71,7 @@
71
71
  "./package.json": "./package.json"
72
72
  }
73
73
  },
74
- "version": "0.6.48",
74
+ "version": "0.6.50",
75
75
  "types": "./lib/index.d.ts",
76
76
  "type": "module",
77
77
  "exports": {
package/src/config.ts CHANGED
@@ -24,7 +24,7 @@ import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
24
24
 
25
25
  import type { MastraAgentDefinition, MastraTools } from "./agents.ts";
26
26
  import type { GenieSpacesConfig } from "./genie.ts";
27
- import type { MastraIdentityMode } from "./identity.ts";
27
+ import { IDENTITY_MODES, type MastraIdentityMode } from "./identity.ts";
28
28
  import type { RemoteSkillsOption } from "./remote-skills.ts";
29
29
 
30
30
  /**
@@ -526,8 +526,13 @@ export interface MastraPluginConfig extends BasePluginConfig {
526
526
  * - `"service-principal"`: always the app service principal. Needs no OBO
527
527
  * scopes and works for any caller who can open the app, at the cost of
528
528
  * per-user attribution in Genie / Unity Catalog.
529
+ * - `"auto"`: OBO when the request carries an OBO token, the service principal
530
+ * when it does not. For an app serving BOTH the platform front door and a
531
+ * door that has no token to forward - a `@dbx-tools/cli-tunnel` gate, a
532
+ * Teams channel - where AppKit's `asUser` would otherwise throw
533
+ * `AuthenticationError` outside `NODE_ENV=development`.
529
534
  *
530
- * `"service-principal"` changes only the Databricks CREDENTIAL. Memory
535
+ * The service-principal path changes only the Databricks CREDENTIAL. Memory
531
536
  * threads, the per-user cache namespace, and trace metadata still key off the
532
537
  * forwarded user, so callers sharing the service principal's data access keep
533
538
  * separate conversations and cannot read each other's threads or charts.
@@ -536,6 +541,11 @@ export interface MastraPluginConfig extends BasePluginConfig {
536
541
  * ```ts
537
542
  * mastra({ genieIdentity: "service-principal", genieSpaces: { default: spaceId } });
538
543
  * ```
544
+ *
545
+ * @example One app behind both the front door and a public tunnel
546
+ * ```ts
547
+ * mastra({ genieIdentity: "auto", genieSpaces: { default: spaceId } });
548
+ * ```
539
549
  */
540
550
  genieIdentity?: MastraIdentityMode;
541
551
  /**
@@ -691,9 +701,11 @@ export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
691
701
  },
692
702
  genieIdentity: {
693
703
  type: "string",
694
- enum: ["user", "service-principal"],
704
+ // Derived from the resolver's own list, so a mode can never be accepted at
705
+ // runtime while the published schema (and the UI built from it) omits it.
706
+ enum: [...IDENTITY_MODES],
695
707
  description:
696
- 'Which Databricks identity the agents\' workspace calls (serving catalogue, Genie, statement execution) run as. "user" (default) is always OBO, so callers must be workspace members; "service-principal" always uses the app service principal, so any caller who can open the app works, at the cost of per-user attribution. Falls back to MASTRA_GENIE_IDENTITY.',
708
+ 'Which Databricks identity the agents\' workspace calls (serving catalogue, Genie, statement execution) run as. "user" (default) is always OBO, so callers must be workspace members; "service-principal" always uses the app service principal, so any caller who can open the app works, at the cost of per-user attribution; "auto" uses OBO when the request carries an OBO token and the service principal when it does not, for an app that also serves a tunnel or webhook door. Falls back to MASTRA_GENIE_IDENTITY.',
697
709
  },
698
710
  apiAccess: {
699
711
  type: "string",
package/src/identity.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  * client on the ambient AppKit execution context. That context is user-scoped
8
8
  * (OBO) whenever the plugin enters `asUser(req)` and the app's service principal
9
9
  * otherwise, so ONE decision - do we enter that scope for this request - moves
10
- * every call site together. This module owns that decision.
10
+ * every call site together.
11
+ *
12
+ * The decision itself is NOT Mastra-specific (the Teams messaging endpoint and
13
+ * the tunnel gate face it too), so it lives in `@dbx-tools/appkit`'s `identity`
14
+ * module - which also documents the measured AppKit behavior that makes `"auto"`
15
+ * necessary. This module is the Mastra-facing binding: the plugin's own config
16
+ * field and env var, re-exporting the shared vocabulary so a consumer importing
17
+ * `@dbx-tools/appkit-mastra` needs no second import.
11
18
  *
12
19
  * Why the option exists: OBO requires the caller to be a member of the
13
20
  * WORKSPACE, not merely of the Databricks account. An app shared with an
@@ -19,7 +26,7 @@
19
26
  * / warehouse / serving / Unity Catalog grants the app was deployed with, and
20
27
  * works for every caller.
21
28
  *
22
- * The two modes, from `config.genieIdentity` (env: {@link IDENTITY_ENV}):
29
+ * The modes, from `config.genieIdentity` (env: {@link IDENTITY_ENV}):
23
30
  *
24
31
  * - `"user"` (default): always OBO. Calls are attributed per user and Genie /
25
32
  * Unity Catalog row filters apply per user. Correct whenever every caller is
@@ -28,9 +35,13 @@
28
35
  * - `"service-principal"`: always the app service principal. Needs no OBO
29
36
  * scopes and works for any caller who can open the app, at the cost of
30
37
  * per-user attribution in Genie / Unity Catalog.
38
+ * - `"auto"`: OBO when the request carries an OBO token, the service principal
39
+ * when it does not. The mode for an app that serves BOTH the platform front
40
+ * door and a door with no OBO token to forward (a `@dbx-tools/cli-tunnel`
41
+ * gate, a Teams channel), since a single container serves both at once.
31
42
  *
32
- * What `"service-principal"` does NOT change is WHO the turn belongs to. The
33
- * memory thread's `resourceId`, the per-user cache namespace, and the user
43
+ * What the service-principal path does NOT change is WHO the turn belongs to.
44
+ * The memory thread's `resourceId`, the per-user cache namespace, and the user
34
45
  * metadata on traces still come from the forwarded request headers, so two
35
46
  * account users sharing the service principal's data access still get separate
36
47
  * conversations and cannot read each other's threads or cached charts. Only the
@@ -39,72 +50,50 @@
39
50
  * @module
40
51
  */
41
52
 
42
- import { ConfigurationError } from "@databricks/appkit";
43
- import { string } from "@dbx-tools/shared-core";
44
- import type express from "express";
53
+ import { identity } from "@dbx-tools/appkit";
45
54
 
46
55
  /** Identity a chat turn's Databricks calls run as. See the module docs. */
47
- export type MastraIdentityMode = "user" | "service-principal";
56
+ export type MastraIdentityMode = identity.IdentityMode;
48
57
 
49
58
  /** Environment fallback for `config.genieIdentity`. */
50
59
  export const IDENTITY_ENV = "MASTRA_GENIE_IDENTITY";
51
60
 
52
61
  /** Every accepted {@link MastraIdentityMode}. */
53
- export const IDENTITY_MODES: readonly MastraIdentityMode[] = ["user", "service-principal"];
62
+ export const IDENTITY_MODES = identity.IDENTITY_MODES;
54
63
 
55
64
  /**
56
65
  * Default mode. `"user"` keeps OBO the only identity unless an app opts in, so
57
66
  * adopting this option can never silently widen an existing app's data access.
58
67
  */
59
- export const DEFAULT_IDENTITY_MODE: MastraIdentityMode = "user";
68
+ export const DEFAULT_IDENTITY_MODE = identity.DEFAULT_IDENTITY_MODE;
69
+
70
+ /** Header Databricks Apps forward the signed-in user's id on. */
71
+ export const USER_ID_HEADER = identity.USER_ID_HEADER;
72
+
73
+ /** Header Databricks Apps forward the signed-in user's email on. */
74
+ export const USER_EMAIL_HEADER = identity.USER_EMAIL_HEADER;
60
75
 
61
76
  /**
62
77
  * Resolve the configured mode: explicit plugin config, then {@link IDENTITY_ENV},
63
- * then {@link DEFAULT_IDENTITY_MODE}.
64
- *
65
- * An unrecognized value throws rather than falling back, since falling back
66
- * would silently keep serving OBO - and the 500s it produces - to exactly the
67
- * callers the option was set to accommodate.
78
+ * then {@link DEFAULT_IDENTITY_MODE}. An unrecognized value throws.
68
79
  */
69
80
  export function resolveIdentityMode(configured: string | undefined): MastraIdentityMode {
70
- const raw = string.trimToNull(configured) ?? string.trimToNull(process.env[IDENTITY_ENV]);
71
- if (raw === null) return DEFAULT_IDENTITY_MODE;
72
- const mode = raw.toLowerCase() as MastraIdentityMode;
73
- if (!IDENTITY_MODES.includes(mode)) {
74
- throw new ConfigurationError(
75
- `genieIdentity must be one of ${IDENTITY_MODES.join(" | ")} (env: ${IDENTITY_ENV})`,
76
- { context: { field: "genieIdentity", envVar: IDENTITY_ENV, received: raw } },
77
- );
78
- }
79
- return mode;
81
+ return identity.resolveIdentityMode(configured, IDENTITY_ENV, "genieIdentity");
80
82
  }
81
83
 
82
- /** Header Databricks Apps forward the signed-in user's id on. */
83
- export const USER_ID_HEADER = "x-forwarded-user";
84
-
85
- /** Header Databricks Apps forward the signed-in user's email on. */
86
- export const USER_EMAIL_HEADER = "x-forwarded-email";
87
-
88
84
  /**
89
85
  * The forwarded user id on `req`, or `undefined`. Used to attribute a turn to
90
- * the real caller in `service-principal` mode, where the Databricks client is
91
- * the app SP but memory / cache / traces must still key off the user.
86
+ * the real caller when the Databricks client is the app SP but memory / cache /
87
+ * traces must still key off the user.
92
88
  */
93
- export function requestUserId(req: express.Request): string | undefined {
94
- return string.trimToNull(req.header(USER_ID_HEADER)) ?? undefined;
95
- }
89
+ export const requestUserId = identity.requestUserId;
96
90
 
97
91
  /** The forwarded user email on `req`, or `undefined`. */
98
- export function requestUserEmail(req: express.Request): string | undefined {
99
- return string.trimToNull(req.header(USER_EMAIL_HEADER)) ?? undefined;
100
- }
92
+ export const requestUserEmail = identity.requestUserEmail;
101
93
 
102
94
  /**
103
95
  * Whether this request should run its Databricks calls as the app service
104
- * principal rather than OBO. `"service-principal"` always does; `"user"` never
105
- * does. The request is accepted for symmetry and to leave room for
106
- * request-scoped policy later; it is unused today.
96
+ * principal rather than OBO - `"service-principal"` always, `"user"` never, and
97
+ * `"auto"` only when the request carries no OBO token for AppKit to use.
107
98
  */
108
- export function useServicePrincipal(mode: MastraIdentityMode, _req?: express.Request): boolean {
109
- return mode === "service-principal";
110
- }
99
+ export const useServicePrincipal = identity.useServicePrincipal;
@@ -40,9 +40,12 @@
40
40
  * action writes to), so provisioned skills persist across restarts and are
41
41
  * discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
42
42
  * explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
43
- * instead. When no Databricks client is resolvable at startup, the tree is
44
- * written under {@link localFS.tmpFS} and returned as an extra local skill path
45
- * for the current process.
43
+ * instead. When no Databricks client is resolvable at startup - or the resolved
44
+ * identity cannot WRITE the destination, which is the normal case for a
45
+ * Databricks App service principal against the admin-owned shared tree (see
46
+ * {@link openWritableWorkspace}) - the tree is written under
47
+ * {@link localFS.tmpFS} and returned as an extra local skill path for the
48
+ * current process. Storage location degrades; the skills themselves do not.
46
49
  *
47
50
  * @module
48
51
  */
@@ -413,25 +416,20 @@ export async function provisionRemoteSkills(
413
416
 
414
417
  const failDefault = options.failOnError !== false;
415
418
  const client = options.client ?? appkit.tryGetExecutionContext()?.client;
416
- const databricksBasePath = resolveDatabricksBasePath(options, client);
417
- const destination = databricksBasePath
418
- ? new DatabricksFileSystem({
419
- client: client as WorkspaceClient,
420
- root: databricksBasePath,
421
- readOnly: false,
422
- createRoot: true,
423
- })
419
+ // Probed rather than assumed: an identity that cannot write the workspace
420
+ // tree falls back to the local one instead of failing startup. The probe also
421
+ // creates the root, which the first cache READ needs - `copySkillDirs` would
422
+ // otherwise be the first thing to create it, which is too late.
423
+ const requestedBasePath = resolveDatabricksBasePath(options, client);
424
+ const destination = requestedBasePath
425
+ ? await openWritableWorkspace(requestedBasePath, client as WorkspaceClient)
424
426
  : undefined;
427
+ const databricksBasePath = destination ? requestedBasePath : undefined;
425
428
 
426
429
  const localSkillPaths: string[] = [];
427
430
  const skillNames: string[] = [];
428
431
  let staging: LocalFileSystem | undefined;
429
432
 
430
- // The destination roots the shared metadata document, so it has to exist
431
- // before the first cache READ - `copySkillDirs` would otherwise be the first
432
- // thing to create it, which is too late.
433
- if (destination) await destination.init();
434
-
435
433
  try {
436
434
  const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
437
435
  for (const entry of sources) {
@@ -508,6 +506,49 @@ export async function provisionRemoteSkills(
508
506
  return { localSkillPaths, databricksBasePath, skillNames };
509
507
  }
510
508
 
509
+ /** Probe file {@link openWritableWorkspace} writes and removes. */
510
+ const WRITE_PROBE_FILE = ".dbx-tools-write-probe";
511
+
512
+ /**
513
+ * Open the Databricks skills destination, or `undefined` when this identity
514
+ * cannot write there.
515
+ *
516
+ * The default destination (`/Workspace/.assistant/skills`) is commonly
517
+ * admins-only, and an app service principal is not an admin - so a Databricks
518
+ * App writing there fails with `RESOURCE_DOES_NOT_EXIST`, which is how the
519
+ * workspace API reports a path the caller is not allowed to see. That is a fact
520
+ * about WHERE the tree is stored, not about whether the skills are usable, so it
521
+ * degrades to the local tree (handed to Mastra as an extra scan path) rather
522
+ * than taking app startup down over bookkeeping location.
523
+ *
524
+ * The probe is a real write: `mkdirs` succeeds on an existing directory even for
525
+ * an identity that cannot write into it, so init alone proves nothing. Removing
526
+ * the probe file is best-effort - a failed cleanup must not decide writability.
527
+ */
528
+ async function openWritableWorkspace(
529
+ basePath: string,
530
+ client: WorkspaceClient,
531
+ ): Promise<DatabricksFileSystem | undefined> {
532
+ const fs = new DatabricksFileSystem({
533
+ client,
534
+ root: basePath,
535
+ readOnly: false,
536
+ createRoot: true,
537
+ });
538
+ try {
539
+ await fs.init();
540
+ await fs.writeFile(WRITE_PROBE_FILE, new Date().toISOString(), { overwrite: true });
541
+ } catch (err) {
542
+ logger.warn("destination:unwritable", {
543
+ destination: basePath,
544
+ error: error.errorMessage(err),
545
+ });
546
+ return undefined;
547
+ }
548
+ await fs.deleteFile(WRITE_PROBE_FILE, { force: true }).catch(() => undefined);
549
+ return fs;
550
+ }
551
+
511
552
  /** Resolve the Databricks Assistant skills destination, or `undefined` for local temp. */
512
553
  function resolveDatabricksBasePath(
513
554
  options: ProvisionRemoteSkillsOptions,