@dbx-tools/appkit-mastra 0.9.106 → 0.9.110

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.
@@ -5,8 +5,8 @@
5
5
  * {@link AITOOLS_SOURCE} constant, a GitHub `owner/repo`, any git / GitLab URL,
6
6
  * or a direct download URL - and optional per-source policy.
7
7
  * {@link provisionRemoteSkills} materializes every source into a local
8
- * `SKILL.md` tree at app boot and returns the directories to hand Mastra as
9
- * extra skill scan paths.
8
+ * `SKILL.md` tree at app boot and returns read-only backing directories plus
9
+ * their stable virtual workspace mounts.
10
10
  *
11
11
  * Resolution per source, in order:
12
12
  *
@@ -43,8 +43,8 @@
43
43
  * identity cannot WRITE the destination, which is the normal case for a
44
44
  * Databricks App service principal against the admin-owned shared tree (see
45
45
  * {@link openWritableWorkspace}) - the tree is written under
46
- * {@link localFS.tmpFS} and returned as an extra local skill path for the
47
- * current process. Storage location degrades; the skills themselves do not.
46
+ * {@link localFS.tmpFS} and exposed through a stable read-only virtual mount
47
+ * for the current process. Storage location degrades; the skills themselves do not.
48
48
  *
49
49
  * @module
50
50
  */
@@ -100,6 +100,12 @@ const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60_000;
100
100
  /** Stable temp directory holding one rebuilt skill tree per remote source. */
101
101
  const LOCAL_SKILLS_DIR = "mastra-local-skills";
102
102
 
103
+ /** Virtual workspace path reserved for locally provisioned Databricks AI Tools. */
104
+ export const AITOOLS_SKILLS_MOUNT = "/.databricks/skills";
105
+
106
+ /** Virtual workspace root for every other locally provisioned remote source. */
107
+ export const REMOTE_SKILLS_MOUNT = "/.skills";
108
+
103
109
  /**
104
110
  * Bookkeeping file written at the root of every provisioned skill tree, naming
105
111
  * when each source was last downloaded. A dotfile so the skill scanners, which
@@ -156,6 +162,8 @@ interface AiToolsManifest {
156
162
  export interface RemoteSkillSourceOptions {
157
163
  /** `"aitools"`, a GitHub shorthand, a git / GitLab URL, or a download URL. */
158
164
  source: net.UrlLike | AiToolsSource;
165
+ /** Stable virtual mount name under `/.skills`; generated from the source when omitted. */
166
+ name?: string;
159
167
  /** Install only these skill names from the source. */
160
168
  skills?: string | string[];
161
169
  /** Include experimental skills. `"aitools"` only. */
@@ -195,6 +203,14 @@ export type RemoteSkillsOption =
195
203
  /** A source entry with its `source` flattened to a plain string. */
196
204
  type NormalizedSource = Omit<RemoteSkillSourceOptions, "source"> & { source: string };
197
205
 
206
+ /** Read-only local backing storage exposed at a stable virtual workspace path. */
207
+ export interface LocalSkillMount {
208
+ /** Physical local directory containing the provisioned `SKILL.md` tree. */
209
+ root: string;
210
+ /** Virtual path agents see inside the composed Mastra workspace. */
211
+ mountPath: string;
212
+ }
213
+
198
214
  /** Top-level remote-skills provisioning options. */
199
215
  export interface ProvisionRemoteSkillsOptions {
200
216
  /** Sources to materialize at startup. */
@@ -226,11 +242,11 @@ export interface ProvisionRemoteSkillsOptions {
226
242
  /** What {@link provisionRemoteSkills} resolved. */
227
243
  export interface ProvisionedRemoteSkills {
228
244
  /**
229
- * Extra LOCAL skill scan paths to hand Mastra (a temp dir per source that
230
- * couldn't be written to Databricks). Empty when everything landed in the
231
- * Databricks Assistant tree, which the built-in mount already scans.
245
+ * Extra read-only LOCAL skill mounts to hand Mastra when a source couldn't
246
+ * be written to Databricks. Physical temp/cache paths remain hidden behind
247
+ * stable virtual workspace paths.
232
248
  */
233
- localSkillPaths: string[];
249
+ localSkillMounts: LocalSkillMount[];
234
250
  /** Absolute Databricks destination each source was written to, if any. */
235
251
  databricksBasePath?: string;
236
252
  /**
@@ -342,6 +358,29 @@ function cacheKey(sourceOptions: NormalizedSource): string {
342
358
  );
343
359
  }
344
360
 
361
+ /** Stable virtual workspace path for one locally provisioned source. */
362
+ function localSkillMountPath(sourceOptions: NormalizedSource, key: string): string {
363
+ if (isAiToolsSource(sourceOptions.source)) return AITOOLS_SKILLS_MOUNT;
364
+ const configuredName = stringUtils.trimToNull(sourceOptions.name);
365
+ const identifier = configuredName
366
+ ? stringUtils.toSlug(configuredName)
367
+ : stringUtils.toSlug(remoteSourceIdentifier(sourceOptions.source)) || "source";
368
+ if (!identifier) {
369
+ throw new Error(
370
+ `remote skill source name must contain letters or numbers: "${configuredName}"`,
371
+ );
372
+ }
373
+ return posix.join(REMOTE_SKILLS_MOUNT, configuredName ? identifier : `${identifier}-${key}`);
374
+ }
375
+
376
+ /** Human-readable source portion used by generated virtual mount identifiers. */
377
+ function remoteSourceIdentifier(source: string): string {
378
+ const url = net.urlBuilder(source);
379
+ if (!url) return source;
380
+ const path = url.pathname.replace(/\.(git|md|zip|tar|tgz|gz)$/i, "");
381
+ return `${url.hostname}-${path}`;
382
+ }
383
+
345
384
  /** The effective reuse window: per-source, then top-level, then seven days. */
346
385
  function resolveRefreshTtl(
347
386
  sourceOptions: NormalizedSource,
@@ -416,13 +455,13 @@ function localSkillsFS(key: string): LocalFileSystem {
416
455
  *
417
456
  * Writes each resolved `SKILL.md` tree to the Databricks user Assistant skills
418
457
  * folder when a writable workspace is available, else under
419
- * {@link localFS.tmpFS} returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
458
+ * {@link localFS.tmpFS} returned in {@link ProvisionedRemoteSkills.localSkillMounts}.
420
459
  */
421
460
  export async function provisionRemoteSkills(
422
461
  option: RemoteSkillsOption | undefined,
423
462
  ): Promise<ProvisionedRemoteSkills> {
424
463
  const options = normalizeRemoteSkillsOption(option);
425
- const empty: ProvisionedRemoteSkills = { localSkillPaths: [], skillNames: [] };
464
+ const empty: ProvisionedRemoteSkills = { localSkillMounts: [], skillNames: [] };
426
465
  if (!options) return empty;
427
466
 
428
467
  const failDefault = options.failOnError !== false;
@@ -437,7 +476,7 @@ export async function provisionRemoteSkills(
437
476
  : undefined;
438
477
  const databricksBasePath = destination ? requestedBasePath : undefined;
439
478
 
440
- const localSkillPaths: string[] = [];
479
+ const localSkillMounts: LocalSkillMount[] = [];
441
480
  const skillNames: string[] = [];
442
481
  let staging: LocalFileSystem | undefined;
443
482
 
@@ -450,15 +489,16 @@ export async function provisionRemoteSkills(
450
489
  // Where this source's bookkeeping lives: the shared Databricks tree, or
451
490
  // the source's own stable local dir.
452
491
  const key = cacheKey(sourceOptions);
492
+ const mountPath = localSkillMountPath(sourceOptions, key);
453
493
  const cacheFS = destination ?? localSkillsFS(key);
454
494
  const metadata = await readMetadata(cacheFS);
455
495
  const cached = metadata?.sources[key];
456
496
  if (cached && isFresh(cached, resolveRefreshTtl(sourceOptions, options))) {
457
497
  skillNames.push(...cached.skills);
458
- if (!destination) localSkillPaths.push(cacheFS.root);
498
+ if (!destination) localSkillMounts.push({ root: cacheFS.root, mountPath });
459
499
  logger.info("remote skill ready", {
460
500
  source: sourceOptions.source,
461
- destination: databricksBasePath ?? cacheFS.root,
501
+ destination: databricksBasePath ?? mountPath,
462
502
  downloadedAt: cached.downloadedAt,
463
503
  skills: cached.skills,
464
504
  cached: true,
@@ -489,12 +529,15 @@ export async function provisionRemoteSkills(
489
529
  // mark a failed provision as fresh and suppress the retry for a day.
490
530
  await writeMetadata(destination, key, record);
491
531
  } else {
492
- localSkillPaths.push(await persistLocally(key, staged, record));
532
+ localSkillMounts.push({
533
+ root: await persistLocally(key, staged, record),
534
+ mountPath,
535
+ });
493
536
  }
494
537
  skillNames.push(...record.skills);
495
538
  logger.info("remote skill installed", {
496
539
  source: sourceOptions.source,
497
- destination: databricksBasePath ?? localSkillPaths.at(-1),
540
+ destination: databricksBasePath ?? localSkillMounts.at(-1)?.mountPath,
498
541
  skills: record.skills,
499
542
  });
500
543
  } catch (err) {
@@ -516,7 +559,22 @@ export async function provisionRemoteSkills(
516
559
  }
517
560
  }
518
561
 
519
- return { localSkillPaths, databricksBasePath, skillNames };
562
+ assertUniqueLocalSkillMounts(localSkillMounts);
563
+ return { localSkillMounts, databricksBasePath, skillNames };
564
+ }
565
+
566
+ /** Fail before workspace composition when two sources claim one virtual mount. */
567
+ function assertUniqueLocalSkillMounts(mounts: readonly LocalSkillMount[]): void {
568
+ const rootsByMount = new Map<string, string>();
569
+ for (const mount of mounts) {
570
+ const previousRoot = rootsByMount.get(mount.mountPath);
571
+ if (previousRoot && previousRoot !== mount.root) {
572
+ throw new Error(
573
+ `multiple remote skill sources resolve to virtual mount "${mount.mountPath}"; give each source a unique name`,
574
+ );
575
+ }
576
+ rootsByMount.set(mount.mountPath, mount.root);
577
+ }
520
578
  }
521
579
 
522
580
  /** Remove directories dropped by one source unless another source still owns them. */
package/src/sandbox.ts CHANGED
@@ -68,7 +68,8 @@ export interface DatabricksSandboxOptions {
68
68
  commandTimeoutMs?: number;
69
69
  /**
70
70
  * Provider used only when Databricks Sandbox is definitively unavailable.
71
- * Defaults to the Node Pydantic Monty runtime; `false` fails instead.
71
+ * Disabled by default. Pass `"monty"`, Monty options, or another Mastra
72
+ * sandbox to enable a fallback explicitly.
72
73
  */
73
74
  fallback?: false | "monty" | MontySandboxOptions | WorkspaceSandbox;
74
75
  }
@@ -123,7 +124,7 @@ export class DatabricksSandbox implements WorkspaceSandbox {
123
124
  this.inactivityTimeout = options.inactivityTimeout ?? DEFAULT_INACTIVITY_TIMEOUT;
124
125
  this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
125
126
  this.commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
126
- this.fallbackConfig = options.fallback ?? "monty";
127
+ this.fallbackConfig = options.fallback ?? false;
127
128
  }
128
129
 
129
130
  get provider(): string {
package/src/workspaces.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  type MastraFileSystemAdapterOptions,
61
61
  } from "./filesystems.ts";
62
62
  import { MontySandbox } from "./monty-sandbox.ts";
63
+ import type { LocalSkillMount } from "./remote-skills.ts";
63
64
  import { DatabricksSandbox, type DatabricksWorkspaceSandboxOptions } from "./sandbox.ts";
64
65
  import { ORGANIZATION_ASSISTANT_PATH, personalWorkspacePath } from "./skill-paths.ts";
65
66
 
@@ -81,7 +82,7 @@ const databricksWorkspaceOptions = new WeakMap<Workspace, DatabricksWorkspaceOpt
81
82
 
82
83
  /* -------------------------------- types -------------------------------- */
83
84
 
84
- /** Per-request context for mount and skill-folder resolvers. */
85
+ /** Per-request context for workspace path resolvers. */
85
86
  export interface DatabricksWorkspaceContext {
86
87
  requestContext?: RequestContext;
87
88
  }
@@ -97,13 +98,9 @@ export interface DatabricksWorkspacePathOptions {
97
98
  /** Mount description shown in filesystem listings and workspace instructions. */
98
99
  description?: string;
99
100
  /**
100
- * Scan this mount for `SKILL.md` files. Defaults to `true`; `false` mounts
101
- * the location for file tools without adding it to skill discovery.
102
- */
103
- readable?: boolean;
104
- /**
105
- * Skill roots relative to this filesystem root. Defaults to `["."]`.
106
- * Each path is joined to the actual mount path before Mastra scans it.
101
+ * Skill paths relative to this included filesystem path. Omit for a
102
+ * file-only mount. Each entry is joined to the actual mount path before
103
+ * Mastra receives it.
107
104
  */
108
105
  skills?: readonly string[];
109
106
  /**
@@ -176,9 +173,9 @@ type WorkspaceMountResolver = (
176
173
  ) => WorkspaceMountContribution | Promise<WorkspaceMountContribution>;
177
174
 
178
175
  /**
179
- * Sandbox selection for {@link databricksWorkspace}. Monty is the default;
180
- * `false` disables command execution, and a Mastra provider or resolver is an
181
- * explicit replacement.
176
+ * Sandbox selection for {@link databricksWorkspace}. Command execution is
177
+ * disabled by default; Monty, Databricks Sandbox, or a Mastra provider/resolver
178
+ * must be selected explicitly.
182
179
  */
183
180
  export type WorkspaceSandboxSelection =
184
181
  | false
@@ -204,26 +201,28 @@ export interface DatabricksWorkspaceOptions extends Omit<
204
201
  paths?: readonly DatabricksWorkspacePathValue[];
205
202
  /** Native Mastra filesystems composed beside the Databricks paths. */
206
203
  mounts?: Record<string, WorkspaceFilesystem>;
207
- /** Replace the auto-built dynamic skills resolver. */
204
+ /** Native Mastra skill paths or resolver composed with path-declared skills. */
208
205
  skills?: SkillsResolver;
209
206
  /** Filesystem cache filters. Ignored when the AppKit files-cache plugin is absent. */
210
207
  cache?: DatabricksWorkspaceCache;
211
208
  /** AppKit plugin context used to discover optional sibling capabilities. */
212
209
  pluginContext?: pluginRegistry.PluginContextLike;
213
210
  /**
214
- * Command sandbox. Defaults to Monty. Pass `"databricks"` or an options
215
- * object for Databricks Sandbox, `false` to disable command execution, or an
216
- * explicit Mastra sandbox/provider resolver.
211
+ * Command sandbox. Disabled by default. Pass `"monty"`, `"databricks"`, or
212
+ * Databricks Sandbox options to enable one of the built-in providers, or pass
213
+ * an explicit Mastra sandbox/provider resolver.
217
214
  */
218
215
  sandbox?: WorkspaceSandboxSelection;
216
+ /** Add user-scoped `/tmp` only when no configured Databricks path mounts writable. */
217
+ fallbackToTmp?: boolean;
219
218
  /**
220
- * Extra LOCAL skill roots mounted read-only for every request's skill discovery.
221
- * Used by the plugin to surface remote skills provisioned to a local temp
222
- * dir at startup (see `remote-skills.ts`). Databricks-hosted remote skills
223
- * need no entry here - they land in the Assistant tree the built-in mount
224
- * already scans.
219
+ * Extra LOCAL skill trees mounted read-only for every request's skill
220
+ * discovery. Used by the plugin to expose remote skills from local backing
221
+ * storage at stable virtual paths (see `remote-skills.ts`). Databricks-hosted
222
+ * remote skills need no entry here because the built-in Assistant mount
223
+ * already scans them.
225
224
  */
226
- extraSkillPaths?: string[];
225
+ extraSkillMounts?: LocalSkillMount[];
227
226
  }
228
227
 
229
228
  /* ------------------------------- defaults ------------------------------- */
@@ -234,14 +233,12 @@ export interface DatabricksWorkspaceOptions extends Omit<
234
233
  export const DEFAULT_DATABRICKS_WORKSPACE_PATHS: readonly DatabricksWorkspacePathOptions[] = [
235
234
  {
236
235
  path: ORGANIZATION_ASSISTANT_PATH,
237
- readable: true,
238
236
  skills: ["skills"],
239
237
  writable: true,
240
238
  createRoot: false,
241
239
  },
242
240
  {
243
241
  path: "~",
244
- readable: true,
245
242
  skills: [".assistant/skills"],
246
243
  writable: true,
247
244
  createRoot: false,
@@ -279,7 +276,7 @@ export function databricksWorkspaceConfig(
279
276
  const filesystemSourceKey = `${id}:${++workspaceSourceSequence}`;
280
277
  const paths = resolveDatabricksWorkspacePaths(options);
281
278
  const configuredPaths = paths.length;
282
- const extraSkillPaths = options.extraSkillPaths ?? [];
279
+ const extraSkillMounts = options.extraSkillMounts ?? [];
283
280
  const filesCache = pluginRegistry.instance(options.pluginContext, filesCachePlugin)?.exports();
284
281
  const configuredCache = options.cache;
285
282
  const mountFilesCache = configuredCache === false ? undefined : filesCache;
@@ -290,9 +287,10 @@ export function databricksWorkspaceConfig(
290
287
  const resolvers = buildMountResolvers(
291
288
  paths,
292
289
  options.mounts,
293
- localSkillMountResolvers(extraSkillPaths),
290
+ localSkillMountResolvers(extraSkillMounts),
294
291
  mountFilesCache,
295
292
  configuredCache,
293
+ options.fallbackToTmp === true,
296
294
  );
297
295
  const resolveContribution = contributionResolver(resolvers);
298
296
  const resolveFilesystem = (context: DatabricksWorkspaceContext) =>
@@ -302,6 +300,7 @@ export function databricksWorkspaceConfig(
302
300
  retainFilesystemSource ? mountFilesCache : undefined,
303
301
  filesystemSourceKey,
304
302
  );
303
+ const hasSkillPaths = paths.some(pathMayProvideSkills) || extraSkillMounts.length > 0;
305
304
  const skills: SkillsResolver =
306
305
  options.skills ??
307
306
  (async ({ requestContext }) => {
@@ -322,10 +321,11 @@ export function databricksWorkspaceConfig(
322
321
  resolverCount: resolvers.length,
323
322
  configuredPaths,
324
323
  customMounts: Object.keys(options.mounts ?? {}).length,
325
- customSkillsResolver: Boolean(options.skills),
324
+ skillsConfigured: options.skills !== undefined || hasSkillPaths,
325
+ customSkillsResolver: options.skills !== undefined,
326
326
  checkSkillFileMtime,
327
327
  bm25,
328
- extraSkillPaths: extraSkillPaths.length,
328
+ extraSkillMounts: extraSkillMounts.length,
329
329
  sandbox: sandbox ? sandboxName(options.sandbox) : "disabled",
330
330
  });
331
331
 
@@ -337,7 +337,8 @@ export function databricksWorkspaceConfig(
337
337
  cache: _cache,
338
338
  pluginContext: _pluginContext,
339
339
  sandbox: _sandbox,
340
- extraSkillPaths: _extraSkillPaths,
340
+ fallbackToTmp: _fallbackToTmp,
341
+ extraSkillMounts: _extraSkillMounts,
341
342
  ...workspaceOptions
342
343
  } = options;
343
344
  return {
@@ -345,7 +346,7 @@ export function databricksWorkspaceConfig(
345
346
  id,
346
347
  name,
347
348
  filesystem: resolveFilesystem,
348
- ...(resolvers.length > 0 || options.skills
349
+ ...(options.skills !== undefined || hasSkillPaths
349
350
  ? {
350
351
  skills,
351
352
  checkSkillFileMtime,
@@ -393,6 +394,13 @@ export function resolveDatabricksWorkspacePaths(
393
394
  ];
394
395
  }
395
396
 
397
+ /** Whether one included path can contribute Mastra skill paths. */
398
+ function pathMayProvideSkills(path: DatabricksWorkspacePathValue): boolean {
399
+ if (typeof path === "function") return true;
400
+ if (path === false || typeof path === "string") return false;
401
+ return (path.skills?.length ?? 0) > 0;
402
+ }
403
+
396
404
  /* ---------------------------- private helpers ---------------------------- */
397
405
 
398
406
  function workspaceTools(configured: WorkspaceToolsConfig | undefined): WorkspaceToolsConfig {
@@ -408,7 +416,7 @@ function resolveWorkspaceSandbox(
408
416
  workspaceId: string,
409
417
  workspaceName: string,
410
418
  ): WorkspaceSandbox | WorkspaceSandboxResolver | undefined {
411
- const configured = selection ?? "monty";
419
+ const configured = selection ?? false;
412
420
  if (configured === false) return undefined;
413
421
  if (configured === "monty") return new MontySandbox();
414
422
  if (typeof configured === "function" || isSandbox(configured)) return configured;
@@ -462,9 +470,9 @@ function isSandbox(value: unknown): value is WorkspaceSandbox {
462
470
  }
463
471
 
464
472
  function sandboxName(selection: WorkspaceSandboxSelection | undefined): string {
465
- if (selection === undefined || selection === "monty") return "monty";
473
+ if (selection === undefined || selection === false) return "disabled";
474
+ if (selection === "monty") return "monty";
466
475
  if (selection === "databricks") return "databricks";
467
- if (selection === false) return "disabled";
468
476
  if (typeof selection === "function") return "resolver";
469
477
  return isSandbox(selection) ? selection.provider : (selection.provider ?? "databricks");
470
478
  }
@@ -504,14 +512,14 @@ async function resolveFilesCacheScope(context: DatabricksWorkspaceContext): Prom
504
512
  };
505
513
  }
506
514
 
507
- /** Join one relative skill root to its actual composite mount path. */
508
- function mountedSkillPath(mount: string, skillRoot: string): string {
509
- if (posixPath.isAbsolute(skillRoot)) {
510
- throw new TypeError(`Skill path must be relative to its mount: ${skillRoot}`);
515
+ /** Join one relative skill path to its actual composite mount path. */
516
+ function mountedSkillPath(mount: string, skillPath: string): string {
517
+ if (posixPath.isAbsolute(skillPath)) {
518
+ throw new TypeError(`Skill path must be relative to its mount: ${skillPath}`);
511
519
  }
512
- const normalized = posixPath.normalize(skillRoot);
520
+ const normalized = posixPath.normalize(skillPath);
513
521
  if (!normalized.ok) {
514
- throw new TypeError(`Skill path escapes its mount: ${skillRoot}`);
522
+ throw new TypeError(`Skill path escapes its mount: ${skillPath}`);
515
523
  }
516
524
  return posixPath.join(posixPath.normalizeRoot(mount), normalized.path.slice(1));
517
525
  }
@@ -523,19 +531,21 @@ function isWorkspaceRoot(root: string): boolean {
523
531
  }
524
532
 
525
533
  /**
526
- * Mount resolver for the named skill folders.
534
+ * Mount resolver for configured Databricks and temporary paths.
527
535
  *
528
536
  * Gates on workspace file scope (or development mode), then mounts every
529
537
  * folder whose location resolves for this request.
530
538
  */
531
- async function resolveSkillFolderMounts(
539
+ async function resolveWorkspacePathMounts(
532
540
  paths: readonly DatabricksWorkspacePathValue[],
533
541
  context: DatabricksWorkspaceContext,
534
542
  filesCache: FilesCacheExports | undefined,
535
543
  cacheConfig: DatabricksWorkspaceCache | undefined,
544
+ fallbackToTmp: boolean,
536
545
  ): Promise<WorkspaceMountContribution> {
537
546
  const mounts: Record<string, WorkspaceFilesystem> = {};
538
547
  const skillPaths: string[] = [];
548
+ let hasWritableMount = false;
539
549
  const requestContext = context.requestContext;
540
550
 
541
551
  const canMountDatabricks = Boolean(requestContext && shouldMountSkillFolders(requestContext));
@@ -550,9 +560,8 @@ async function resolveSkillFolderMounts(
550
560
  if (posixPath.isWithinRoot(SCRATCH_MOUNT, configured.path)) {
551
561
  const mount = configured.mount ?? configured.path;
552
562
  mounts[mount] = userTempFilesystem(context, configured.path);
553
- if (configured.readable !== false) {
554
- skillPaths.push(...(configured.skills ?? []).map((path) => mountedSkillPath(mount, path)));
555
- }
563
+ hasWritableMount = true;
564
+ skillPaths.push(...(configured.skills ?? []).map((path) => mountedSkillPath(mount, path)));
556
565
  continue;
557
566
  }
558
567
  if (!canMountDatabricks) {
@@ -573,12 +582,15 @@ async function resolveSkillFolderMounts(
573
582
  continue;
574
583
  }
575
584
  mounts[mount] = resolved.filesystem;
576
- if (configured.readable !== false) {
577
- skillPaths.push(...(configured.skills ?? ["."]).map((path) => mountedSkillPath(mount, path)));
578
- }
585
+ hasWritableMount ||= resolved.writable;
586
+ skillPaths.push(...(configured.skills ?? []).map((path) => mountedSkillPath(mount, path)));
579
587
  }
580
588
 
581
- logger.debug("skill-folders:mounted", {
589
+ if (fallbackToTmp && !hasWritableMount) {
590
+ mounts[SCRATCH_MOUNT] = userTempFilesystem(context, SCRATCH_MOUNT);
591
+ }
592
+
593
+ logger.debug("workspace-paths:mounted", {
582
594
  mountKeys: Object.keys(mounts),
583
595
  skillPaths,
584
596
  });
@@ -593,7 +605,6 @@ interface ResolvedDatabricksWorkspacePath {
593
605
  path: string;
594
606
  displayName?: string;
595
607
  description?: string;
596
- readable?: boolean;
597
608
  skills?: readonly string[];
598
609
  writable?: boolean;
599
610
  createRoot?: boolean;
@@ -634,12 +645,13 @@ async function resolveSkillFolderFilesystem(
634
645
  | {
635
646
  filesystem: WorkspaceFilesystem;
636
647
  root?: string;
648
+ writable: boolean;
637
649
  }
638
650
  | undefined
639
651
  > {
640
652
  // A path mount needs the request's OBO client to reach the workspace.
641
653
  if (!cache.client) {
642
- logger.debug("skill-folder:skipped", {
654
+ logger.debug("workspace-path:skipped", {
643
655
  path: folder.path,
644
656
  reason: "missing-obo-client",
645
657
  });
@@ -649,12 +661,7 @@ async function resolveSkillFolderFilesystem(
649
661
  if (!root) return undefined;
650
662
  const writable = folder.writable ?? isWorkspaceRoot(root);
651
663
  const cacheOptions = cache.fileSystemCache
652
- ? await resolveFileCacheOptions(
653
- cacheConfig,
654
- context,
655
- root,
656
- folder.readable === false ? [] : (folder.skills ?? ["."]),
657
- )
664
+ ? await resolveFileCacheOptions(cacheConfig, context, root, folder.skills ?? [])
658
665
  : undefined;
659
666
  const resolved = await databricksFilesystem(
660
667
  cache.client,
@@ -668,7 +675,7 @@ async function resolveSkillFolderFilesystem(
668
675
  ...(folder.displayName ? { displayName: folder.displayName } : {}),
669
676
  },
670
677
  );
671
- return resolved ? { ...resolved, root } : undefined;
678
+ return resolved ? { ...resolved, root, writable } : undefined;
672
679
  }
673
680
 
674
681
  async function resolveCacheConfig(
@@ -782,18 +789,21 @@ function resolveWorkspaceIdentity(options: DatabricksWorkspaceOptions): {
782
789
  return { id, name };
783
790
  }
784
791
 
785
- /** Collect the skill-folder resolver and any caller-supplied ones. */
792
+ /** Collect the Databricks path resolver and any caller-supplied mounts. */
786
793
  function buildMountResolvers(
787
794
  paths: readonly DatabricksWorkspacePathValue[],
788
795
  mounts: Record<string, WorkspaceFilesystem> | undefined,
789
796
  localMounts: WorkspaceMountResolver[],
790
797
  filesCache: FilesCacheExports | undefined,
791
798
  cacheConfig: DatabricksWorkspaceCache | undefined,
799
+ fallbackToTmp: boolean,
792
800
  ): WorkspaceMountResolver[] {
793
801
  const resolvers: WorkspaceMountResolver[] = [];
794
802
  const pathCount = paths.length;
795
- if (pathCount > 0) {
796
- resolvers.push((context) => resolveSkillFolderMounts(paths, context, filesCache, cacheConfig));
803
+ if (pathCount > 0 || fallbackToTmp) {
804
+ resolvers.push((context) =>
805
+ resolveWorkspacePathMounts(paths, context, filesCache, cacheConfig, fallbackToTmp),
806
+ );
797
807
  }
798
808
  if (mounts && Object.keys(mounts).length > 0) resolvers.push(() => ({ mounts }));
799
809
  resolvers.push(...localMounts);
@@ -830,10 +840,16 @@ function userTempFilesystem(
830
840
  });
831
841
  }
832
842
 
833
- /** Mount startup-provisioned local skill roots into Mastra's workspace filesystem. */
834
- function localSkillMountResolvers(paths: readonly string[]): WorkspaceMountResolver[] {
835
- return [...new Set(paths.map((path) => path.trim()).filter(Boolean))].map((root) => {
836
- const mount = posixPath.normalizeRoot(root);
843
+ /** Mount startup-provisioned local skill trees into Mastra's workspace filesystem. */
844
+ function localSkillMountResolvers(mounts: readonly LocalSkillMount[]): WorkspaceMountResolver[] {
845
+ const unique = new Map<string, LocalSkillMount>();
846
+ for (const entry of mounts) {
847
+ const root = entry.root.trim();
848
+ if (!root) continue;
849
+ unique.set(`${entry.mountPath}\0${root}`, { root, mountPath: entry.mountPath });
850
+ }
851
+ return [...unique.values()].map(({ root, mountPath }) => {
852
+ const mount = posixPath.normalizeRoot(mountPath);
837
853
  const source = new LocalFileSystem({
838
854
  root,
839
855
  readOnly: true,
@@ -1055,30 +1071,30 @@ async function resolveWorkspaceFilesystem(
1055
1071
  }
1056
1072
 
1057
1073
  /**
1058
- * Expand skill roots into concrete skill directories and keep the first
1074
+ * Expand configured skill paths into concrete skill directories and keep the first
1059
1075
  * same-named directory. Mastra still owns parsing, indexing, and refreshes,
1060
1076
  * while earlier configured roots retain precedence over later roots.
1061
1077
  */
1062
1078
  async function resolveDistinctSkillPaths(
1063
1079
  filesystem: WorkspaceFilesystem,
1064
- roots: readonly string[],
1080
+ paths: readonly string[],
1065
1081
  ): Promise<string[]> {
1066
1082
  const selected = new Map<string, string>();
1067
- for (const root of roots) {
1083
+ for (const path of paths) {
1068
1084
  try {
1069
- const entries = await filesystem.readdir(root);
1085
+ const entries = await filesystem.readdir(path);
1070
1086
  if (entries.some((entry) => entry.type === "file" && entry.name === "SKILL.md")) {
1071
- const name = posixPath.basename(root);
1072
- if (!selected.has(name)) selected.set(name, root);
1087
+ const name = posixPath.basename(path);
1088
+ if (!selected.has(name)) selected.set(name, path);
1073
1089
  continue;
1074
1090
  }
1075
1091
  for (const entry of entries) {
1076
1092
  if (entry.type !== "directory" || selected.has(entry.name)) continue;
1077
- selected.set(entry.name, posixPath.join(root, entry.name));
1093
+ selected.set(entry.name, posixPath.join(path, entry.name));
1078
1094
  }
1079
1095
  } catch (error) {
1080
- logger.debug("skill-root:skipped", {
1081
- root,
1096
+ logger.debug("skill-path:skipped", {
1097
+ path,
1082
1098
  error: errorUtils.errorMessage(error),
1083
1099
  });
1084
1100
  }