@mycadet/assistant 0.12.8 → 0.12.9

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 (42) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  2. package/node_modules/@mycadet/ces-client/package.json +1 -1
  3. package/node_modules/@mycadet/ces-client/src/__tests__/ces-client.test.ts +14 -14
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/package.json +1 -1
  5. package/node_modules/@mycadet/service-contracts/package.json +1 -1
  6. package/openapi.yaml +2 -18
  7. package/package.json +2 -2
  8. package/src/__tests__/cloud-profile-guard.test.ts +21 -19
  9. package/src/__tests__/config-set-route.test.ts +12 -8
  10. package/src/__tests__/credential-health-service.test.ts +4 -2
  11. package/src/__tests__/get-skill-detail-audit.test.ts +134 -289
  12. package/src/__tests__/mcp-abort-signal.test.ts +0 -1
  13. package/src/__tests__/mcp-client-auth.test.ts +0 -1
  14. package/src/__tests__/mcp-config-secret-boundary.test.ts +44 -40
  15. package/src/__tests__/mcp-tool-annotations-risk.test.ts +0 -1
  16. package/src/__tests__/plugin-api-store-credential.test.ts +4 -1
  17. package/src/__tests__/skills-file-content-endpoint.test.ts +221 -535
  18. package/src/__tests__/skills-files-catalog-fallback.test.ts +258 -576
  19. package/src/api/events/show-cloud-login.ts +2 -2
  20. package/src/cli/commands/cloud/__tests__/callback-routes-list.test.ts +2 -2
  21. package/src/cli/commands/cloud/index.ts +1 -1
  22. package/src/cli/commands/config.ts +1 -1
  23. package/src/config/__tests__/substrate-twin-shadowing.test.ts +36 -26
  24. package/src/config/env-registry.ts +2 -9
  25. package/src/config/schemas/ingress.ts +30 -0
  26. package/src/config/schemas/llm.ts +0 -9
  27. package/src/config/seed-inference-profiles.ts +2 -2
  28. package/src/config/substrate-twin-shadowing.ts +12 -17
  29. package/src/config/sync-gated-profiles.ts +3 -4
  30. package/src/credential-execution/prompted-credential.ts +1 -10
  31. package/src/credential-health/credential-health-service.ts +8 -4
  32. package/src/daemon/credential-transcript-scrub.ts +11 -0
  33. package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +5 -0
  34. package/src/providers/inference/backfill.ts +2 -2
  35. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +100 -57
  36. package/src/runtime/routes/cloud-routes.ts +5 -5
  37. package/src/runtime/routes/conversation-query-routes.ts +275 -397
  38. package/src/runtime/routes/secret-routes.ts +2 -17
  39. package/src/tools/credentials/store.ts +1 -10
  40. package/src/util/layout.ts +479 -0
  41. package/src/util/platform.ts +11 -467
  42. package/src/workspace/migrations/127-backfill-default-provider.ts +2 -2
@@ -94,11 +94,7 @@ async function persistCredential(
94
94
  `Failed to store credential in secure storage (backend: ${getActiveBackendName()})`,
95
95
  );
96
96
  }
97
- const scrubbed = await scrubStoredCredentialFromTranscripts(value);
98
- log.info(
99
- { service, field, ...scrubbed },
100
- "Credential stored; scrubbed value from recent transcripts",
101
- );
97
+ await scrubStoredCredentialFromTranscripts(value, { service, field });
102
98
  upsertCredentialMetadata(service, field, {});
103
99
  await syncManualTokenConnection(service);
104
100
  }
@@ -308,18 +304,7 @@ async function handleAddSecret({ body }: RouteHandlerArgs) {
308
304
  );
309
305
  }
310
306
 
311
- // The stored plaintext may already sit in recent transcripts (a pasted
312
- // key echoed by a tool result or persisted tool_use input). The scrub
313
- // runs immediately after the secure-store write, BEFORE the provider
314
- // refresh: that side effect can throw, and a stored-but-unscrubbed key
315
- // must not depend on it succeeding. The key IS stored at this point;
316
- // the scrub is best-effort hygiene and must stay invisible to the
317
- // Counts only — never the value.
318
- const scrubbed = await scrubStoredCredentialFromTranscripts(value);
319
- log.info(
320
- { provider: name, ...scrubbed },
321
- "API key stored; scrubbed value from recent transcripts",
322
- );
307
+ await scrubStoredCredentialFromTranscripts(value, { provider: name });
323
308
 
324
309
  await refreshProvidersAfterSecretChange();
325
310
  log.info({ provider: name }, "API key updated via HTTP");
@@ -131,19 +131,10 @@ export async function storeCredentialValue(
131
131
  );
132
132
  }
133
133
 
134
- // The stored plaintext may already sit in recent transcripts: the user
135
- // message that pasted it, the persisted tool_use input, the tool result
136
- // echoing the command. This is the scrub seam, not setSecureKeyAsync, which
137
- // also fires on OAuth refresh rotations and MCP header writes whose values
138
- // never transited a transcript.
139
134
  if (!input.skipTranscriptScrub) {
140
135
  const { scrubStoredCredentialFromTranscripts } =
141
136
  await import("../../daemon/credential-transcript-scrub.js");
142
- const scrubbed = await scrubStoredCredentialFromTranscripts(value);
143
- log.info(
144
- { service, field, ...scrubbed },
145
- "Credential stored; scrubbed value from recent transcripts",
146
- );
137
+ await scrubStoredCredentialFromTranscripts(value, { service, field });
147
138
  }
148
139
 
149
140
  const metadata = upsertCredentialMetadata(service, field, {
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Directory layout helpers: every canonical path the daemon reads and
3
+ * writes, all rooted at the workspace ($CADET_WORKSPACE_DIR) and its
4
+ * internal data directory.
5
+ *
6
+ * Extracted from `util/platform.ts` — these are directory-layout concerns,
7
+ * not platform-detection concerns. `platform.ts` re-exports this module for
8
+ * backward compatibility; new code should import from here directly.
9
+ */
10
+
11
+ import { chmodSync, existsSync, mkdirSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
14
+
15
+ import {
16
+ AVATAR_IMAGE_FILENAME,
17
+ AVATAR_MANIFEST_FILENAME,
18
+ resolveAvatarDir,
19
+ } from "@mycadet/avatar-manifest";
20
+ import { assertTestPathIsEphemeral as assertEphemeralInTests } from "@mycadet/environments/test-path-guard";
21
+
22
+ /**
23
+ * Returns the internal data directory ($CADET_WORKSPACE_DIR/data). Runtime
24
+ * databases, logs, memory indices, and other internal state live here.
25
+ */
26
+ export function getDataDir(): string {
27
+ return join(getWorkspaceDir(), "data");
28
+ }
29
+
30
+ /**
31
+ * Returns the path to the config-quarantine notice sentinel
32
+ * (`<workspace>/data/config-quarantine-notice.json`).
33
+ *
34
+ * Written by the config loader when a corrupt `config.json` is quarantined and
35
+ * read by the per-turn `config-quarantine-notice` injector. Lives under the
36
+ * internal data dir (runtime state, config-free to resolve) rather than the
37
+ * user-facing workspace root because it is daemon-written bookkeeping, not a
38
+ * file the user edits. The path resolves without loading config, so it is safe
39
+ * to call during early-boot config load before the DB or `getConfig().dataDir`
40
+ * exist.
41
+ */
42
+ export function getConfigQuarantineNoticePath(): string {
43
+ return join(getDataDir(), "config-quarantine-notice.json");
44
+ }
45
+
46
+ /**
47
+ * Returns the path to the config-validation-reset notice sentinel
48
+ * (`<workspace>/data/config-validation-reset-notice.json`).
49
+ *
50
+ * Written by the config loader when `config.json` parses as JSON but fails
51
+ * schema validation so hard that the loader falls back to *full* defaults
52
+ * (e.g. an unknown key that masks a `superRefine` violation until the offending
53
+ * key is stripped). Unlike a quarantine, the on-disk file is left untouched —
54
+ * the user's customized values are still present but inactive until the invalid
55
+ * entries are fixed. Read by the per-turn `config-validation-reset-notice`
56
+ * injector so the agent can explain a settings/connection change the user did
57
+ * not make. Lives beside the quarantine sentinel under the internal data dir
58
+ * for the same reasons (daemon-written bookkeeping; resolves without loading
59
+ * config, so it is safe during early-boot config load).
60
+ */
61
+ export function getConfigValidationResetNoticePath(): string {
62
+ return join(getDataDir(), "config-validation-reset-notice.json");
63
+ }
64
+
65
+ /**
66
+ * Returns the embedding models directory ($CADET_WORKSPACE_DIR/embedding-models).
67
+ * Downloaded embedding runtime (onnxruntime-node, transformers bundle, model weights)
68
+ * is stored here, downloaded post-hatch rather than shipped with the app.
69
+ */
70
+ export function getEmbeddingModelsDir(): string {
71
+ return join(getWorkspaceDir(), "embedding-models");
72
+ }
73
+
74
+ /**
75
+ * Returns the sandbox root directory (<workspace>/data/sandbox).
76
+ * Global sandbox state lives under this directory.
77
+ */
78
+ export function getSandboxRootDir(): string {
79
+ return join(getDataDir(), "sandbox");
80
+ }
81
+
82
+ /**
83
+ * Returns the default sandbox working directory ($CADET_WORKSPACE_DIR).
84
+ * This is the workspace root — tool working directories should use this
85
+ * path unless explicitly overridden.
86
+ */
87
+ export function getSandboxWorkingDir(): string {
88
+ return getWorkspaceDir();
89
+ }
90
+
91
+ /**
92
+ * Returns the sounds directory ($CADET_WORKSPACE_DIR/data/sounds).
93
+ * Custom sound files and sound configuration live here.
94
+ */
95
+ export function getSoundsDir(): string {
96
+ return join(getWorkspaceDir(), "data", "sounds");
97
+ }
98
+
99
+ /** Returns the avatar directory ($CADET_WORKSPACE_DIR/data/avatar). */
100
+ export function getAvatarDir(): string {
101
+ return resolveAvatarDir(getWorkspaceDir());
102
+ }
103
+
104
+ /** Returns the canonical avatar image path ($CADET_WORKSPACE_DIR/data/avatar/avatar-image.png). */
105
+ export function getAvatarImagePath(): string {
106
+ return join(getAvatarDir(), AVATAR_IMAGE_FILENAME);
107
+ }
108
+
109
+ /** Returns the canonical avatar manifest path ($CADET_WORKSPACE_DIR/data/avatar/avatar.json). */
110
+ export function getAvatarManifestPath(): string {
111
+ return join(getAvatarDir(), AVATAR_MANIFEST_FILENAME);
112
+ }
113
+
114
+ export function getPidPath(): string {
115
+ return join(getWorkspaceDir(), "cadet.pid");
116
+ }
117
+
118
+ export function getDbPath(): string {
119
+ return join(getDataDir(), "db", "assistant.db");
120
+ }
121
+
122
+ /**
123
+ * Returns the directory where logs live: `<dataDir>/logs/`. Files rotate
124
+ * daily (`assistant-YYYY-MM-DD.log`), so callers ask for the directory and
125
+ * let the logger own the filename.
126
+ */
127
+ export function getLogsDir(): string {
128
+ return join(getDataDir(), "logs");
129
+ }
130
+
131
+ export function getHistoryPath(): string {
132
+ return join(getDataDir(), "history");
133
+ }
134
+
135
+ /** Returns $CADET_WORKSPACE_DIR/signals — the directory for IPC signal files. */
136
+ export function getSignalsDir(): string {
137
+ return join(getWorkspaceDir(), "signals");
138
+ }
139
+
140
+ // These expose specific root-level file paths so callers don't need to
141
+ // import getRootDir() directly. getRootDir() is intentionally unexported.
142
+
143
+ /** Returns the path to the daemon stderr log ($CADET_WORKSPACE_DIR/logs/daemon-stderr.log). */
144
+ export function getDaemonStderrLogPath(): string {
145
+ return join(getWorkspaceDir(), "logs", "daemon-stderr.log");
146
+ }
147
+
148
+ /** Returns the path to the daemon startup lock file ($CADET_WORKSPACE_DIR/daemon-startup.lock). */
149
+ export function getDaemonStartupLockPath(): string {
150
+ return join(getWorkspaceDir(), "daemon-startup.lock");
151
+ }
152
+
153
+ /** Returns the directory for externally-installed packages ($CADET_WORKSPACE_DIR/external). */
154
+ export function getExternalDir(): string {
155
+ return join(getWorkspaceDir(), "external");
156
+ }
157
+
158
+ /** Returns the directory for installed binaries ($CADET_WORKSPACE_DIR/bin). */
159
+ export function getBinDir(): string {
160
+ return join(getWorkspaceDir(), "bin");
161
+ }
162
+
163
+ /**
164
+ * Returns the path to the dot-env file (`<cadetRoot>/.env`, the parent of
165
+ * the workspace directory). Stays outside the workspace sandbox boundary
166
+ * because it contains secrets.
167
+ */
168
+ export function getDotEnvPath(): string {
169
+ return join(dirname(getWorkspaceDir()), ".env");
170
+ }
171
+
172
+ /** Returns the path to the embed-worker PID file ($CADET_WORKSPACE_DIR/embed-worker.pid). */
173
+ export function getEmbedWorkerPidPath(): string {
174
+ return join(getWorkspaceDir(), "embed-worker.pid");
175
+ }
176
+
177
+ /** Returns the path to the memory-worker PID file ($CADET_WORKSPACE_DIR/memory-worker.pid). */
178
+ export function getMemoryWorkerPidPath(): string {
179
+ return join(getWorkspaceDir(), "memory-worker.pid");
180
+ }
181
+
182
+ /** Returns the path to the schedule-worker PID file ($CADET_WORKSPACE_DIR/schedule-worker.pid). */
183
+ export function getScheduleWorkerPidPath(): string {
184
+ return join(getWorkspaceDir(), "schedule-worker.pid");
185
+ }
186
+
187
+ /**
188
+ * Returns the directory where the resource monitor persists its forensics
189
+ * ($CADET_WORKSPACE_DIR/data/monitoring). Lives on the workspace volume (the
190
+ * PVC) so the sample ring buffer and high-memory snapshots survive an OOM
191
+ * SIGKILL that resets all in-process state. Git-ignored from the workspace
192
+ * tree (see `data/monitoring/` in git-service.ts) so the assistant's own
193
+ * telemetry is not auto-committed as user changes.
194
+ */
195
+ export function getMonitoringDataDir(): string {
196
+ return join(getDataDir(), "monitoring");
197
+ }
198
+
199
+ /** Returns the path to the monitoring PID file, under the monitor data dir. */
200
+ export function getMonitoringPidPath(): string {
201
+ return join(getMonitoringDataDir(), "monitoring.pid");
202
+ }
203
+
204
+ /**
205
+ * Root holding every daemon-managed subprocess's runtime directory
206
+ * ($CADET_WORKSPACE_DIR/procs). Each managed subprocess keeps its IPC socket,
207
+ * PID file, and per-process scratch under `procs/<name>/`, so `ls procs` is a
208
+ * census of managed subprocesses and cleanup is one `rm -rf` of the subdir.
209
+ */
210
+ export function getProcsDir(): string {
211
+ return join(getWorkspaceDir(), "procs");
212
+ }
213
+
214
+ /** The runtime directory for one managed subprocess: `procs/<name>/`. */
215
+ export function getProcDir(name: string): string {
216
+ return join(getProcsDir(), name);
217
+ }
218
+
219
+ /** Create (if needed) and return a cloud subprocess's runtime directory. */
220
+ export function ensureProcDir(name: string): string {
221
+ const dir = getProcDir(name);
222
+ mkdirSync(dir, { recursive: true });
223
+ return dir;
224
+ }
225
+
226
+ /**
227
+ * The IPC socket a cloud subprocess binds and the daemon connects to
228
+ * (`procs/<name>/ipc.sock`). The basename is fixed — the directory already
229
+ * carries the subprocess name. Keep it short: Unix `sun_path` is ~104–108 bytes.
230
+ */
231
+ export function getProcSocketPath(name: string): string {
232
+ return join(getProcDir(name), "ipc.sock");
233
+ }
234
+
235
+ /** The PID file a cloud subprocess writes on readiness (`procs/<name>/<name>.pid`). */
236
+ export function getProcPidPath(name: string): string {
237
+ return join(getProcDir(name), `${name}.pid`);
238
+ }
239
+
240
+ // --- Live-workspace guard for test processes --------------------------------
241
+ //
242
+ // A test process must never resolve the workspace (or the cadet root) to a
243
+ // real, non-temp directory: production code exercised by a test would then
244
+ // read and destructively write live state. The containment assertion is
245
+ // shared with the gateway via @mycadet/environments/test-path-guard;
246
+ // src/__tests__/assert-not-live-db.ts keeps its own containment check
247
+ // because test machinery must not import production dependencies.
248
+
249
+ function assertTestPathIsEphemeral(dir: string): void {
250
+ assertEphemeralInTests(dir, {
251
+ // Shared with assertTestDbIsIsolated() in persistence/db-connection.ts.
252
+ allowEnvVar: "CADET_ALLOW_REAL_WORKSPACE_IN_TESTS",
253
+ runHint: "Run tests from the assistant package root.",
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Returns the workspace root for user-facing state.
259
+ *
260
+ * When the CADET_WORKSPACE_DIR env var is set, returns that value (used in
261
+ * containerized deployments where the workspace is a separate volume).
262
+ * Otherwise falls back to `<cwd>/workspace`.
263
+ *
264
+ * In test processes the resolved directory must live under `os.tmpdir()`
265
+ * (see the live-workspace guard above); anything else throws.
266
+ */
267
+ export function getWorkspaceDir(): string {
268
+ const dir =
269
+ process.env.CADET_WORKSPACE_DIR?.trim() || join(process.cwd(), "workspace");
270
+ assertTestPathIsEphemeral(dir);
271
+ return dir;
272
+ }
273
+
274
+ /**
275
+ * Returns a display-friendly workspace path for embedding in agent-facing text
276
+ * (skill bodies, tool descriptions). Replaces the home directory prefix with `~`
277
+ * so paths stay concise and portable across machines.
278
+ *
279
+ * Examples:
280
+ * /Users/alice/.local/share/cadet/workspace → ~/.local/share/cadet/workspace
281
+ * /workspace → /workspace
282
+ */
283
+ export function getWorkspaceDirDisplay(): string {
284
+ return formatHomeRelativePath(getWorkspaceDir(), homedir());
285
+ }
286
+
287
+ interface PathOperations {
288
+ isAbsolute(path: string): boolean;
289
+ relative(from: string, to: string): string;
290
+ sep: string;
291
+ }
292
+
293
+ export function formatHomeRelativePath(
294
+ absolutePath: string,
295
+ homePath: string,
296
+ pathOperations: PathOperations = { isAbsolute, relative, sep },
297
+ ): string {
298
+ const relativeToHome = pathOperations.relative(homePath, absolutePath);
299
+ if (relativeToHome === "") {
300
+ return "~";
301
+ }
302
+ if (
303
+ relativeToHome !== ".." &&
304
+ !relativeToHome.startsWith(`..${pathOperations.sep}`) &&
305
+ !pathOperations.isAbsolute(relativeToHome)
306
+ ) {
307
+ return `~${pathOperations.sep}${relativeToHome}`;
308
+ }
309
+ return absolutePath;
310
+ }
311
+
312
+ /** Returns $CADET_WORKSPACE_DIR/config.json */
313
+ export function getWorkspaceConfigPath(): string {
314
+ return join(getWorkspaceDir(), "config.json");
315
+ }
316
+
317
+ /** Filename for the user-owned Agent Plugins MCP document. */
318
+ export const WORKSPACE_MCP_FILENAME = "mcp.json";
319
+
320
+ /** Returns $CADET_WORKSPACE_DIR/mcp.json */
321
+ export function getWorkspaceMcpConfigPath(): string {
322
+ return join(getWorkspaceDir(), WORKSPACE_MCP_FILENAME);
323
+ }
324
+
325
+ /** Returns $CADET_WORKSPACE_DIR/skills */
326
+ export function getWorkspaceSkillsDir(): string {
327
+ return join(getWorkspaceDir(), "skills");
328
+ }
329
+
330
+ /** Returns $CADET_WORKSPACE_DIR/hooks */
331
+ export function getWorkspaceHooksDir(): string {
332
+ return join(getWorkspaceDir(), "hooks");
333
+ }
334
+
335
+ /**
336
+ * Returns `<workspaceDir>/plugins` — the directory scanned by the user plugin
337
+ * loader at daemon startup. Writes here are security-sensitive: any
338
+ * `register.{ts,js}` will be dynamic-imported on next restart, so the file
339
+ * risk classifier escalates writes under this path to High.
340
+ */
341
+ export function getWorkspacePluginsDir(): string {
342
+ return join(getWorkspaceDir(), "plugins");
343
+ }
344
+
345
+ /**
346
+ * Returns $CADET_WORKSPACE_DIR/tools — user-defined tool overrides.
347
+ *
348
+ * Each subdirectory `<name>/` provides either an override of a core tool of
349
+ * the same name or a net-new tool. The single canonical location removes
350
+ * the "which plugin wins" ambiguity that would arise if multiple plugins
351
+ * could register competing overrides for the same tool.
352
+ *
353
+ * Files under this directory are dynamic-imported by the workspace-tool
354
+ * loader on daemon start; the file risk classifier escalates writes under
355
+ * this path to High for the same reason `plugins/` is escalated.
356
+ */
357
+ export function getWorkspaceToolsDir(): string {
358
+ return join(getWorkspaceDir(), "tools");
359
+ }
360
+
361
+ /**
362
+ * Returns $CADET_WORKSPACE_DIR/routes — user-defined HTTP route handlers.
363
+ *
364
+ * Handler modules under this directory are dynamic-imported by the user-route
365
+ * dispatcher and their exported HTTP-method functions are executed on the
366
+ * next matching request, so the file risk classifier escalates writes under
367
+ * this path to High for the same reason `plugins/` and `tools/` are escalated.
368
+ */
369
+ export function getWorkspaceRoutesDir(): string {
370
+ return join(getWorkspaceDir(), "routes");
371
+ }
372
+
373
+ /**
374
+ * Returns $CADET_WORKSPACE_DIR/workflows — saved (named) workflow scripts.
375
+ *
376
+ * A file here becomes a saved workflow whose source is executed (in the sandbox,
377
+ * and unattended when triggered by a schedule), so the file risk classifier
378
+ * escalates writes under this path to High like `tools/` and `routes/`.
379
+ */
380
+ export function getWorkspaceWorkflowsDir(): string {
381
+ return join(getWorkspaceDir(), "workflows");
382
+ }
383
+
384
+ /** Returns $CADET_WORKSPACE_DIR/deprecated — transitional files slated for removal. */
385
+ export function getDeprecatedDir(): string {
386
+ return join(getWorkspaceDir(), "deprecated");
387
+ }
388
+
389
+ /** Returns $CADET_WORKSPACE_DIR/conversations */
390
+ export function getConversationsDir(): string {
391
+ return join(getWorkspaceDir(), "conversations");
392
+ }
393
+
394
+ /** Returns the workspace path for a prompt file (e.g. IDENTITY.md, SOUL.md). */
395
+ export function getWorkspacePromptPath(file: string): string {
396
+ return join(getWorkspaceDir(), file);
397
+ }
398
+
399
+ /**
400
+ * Returns `<workspaceDir>/prompts/system` — the workspace override layer for
401
+ * system prompt sections. Layout: `prompts/system/<NN-name>.md`.
402
+ *
403
+ * The bundled section registry (`prompts/templates/system-sections.ts`) is
404
+ * the source of default truth; a file here with the same id replaces the
405
+ * bundled body (or, stripped to nothing, silences it), and a brand-new
406
+ * `<NN-name>` adds a workspace-only section. Because that includes the
407
+ * security-policy sections, writes under this directory are gated as a
408
+ * control-plane prompt surface (`permissions/workspace-policy.ts`).
409
+ */
410
+ export function getWorkspaceSystemPromptDir(): string {
411
+ return join(getWorkspaceDir(), "prompts", "system");
412
+ }
413
+
414
+ // Cloud profiler runs live under <workspace>/data/profiler/. These
415
+ // helpers enforce a single canonical layout so every runtime caller
416
+ // resolves the same paths.
417
+
418
+ /**
419
+ * Returns the profiler root directory (<workspace>/data/profiler).
420
+ * All profiler state (runs directory, global metadata) lives here.
421
+ */
422
+ export function getProfilerRootDir(): string {
423
+ return join(getDataDir(), "profiler");
424
+ }
425
+
426
+ /**
427
+ * Returns the profiler runs directory (<workspace>/data/profiler/runs).
428
+ * Each completed or active profiler run gets its own sub-directory here.
429
+ */
430
+ export function getProfilerRunsDir(): string {
431
+ return join(getProfilerRootDir(), "runs");
432
+ }
433
+
434
+ /**
435
+ * Returns the directory for a specific profiler run by ID
436
+ * (<workspace>/data/profiler/runs/<runId>).
437
+ */
438
+ export function getProfilerRunDir(runId: string): string {
439
+ return join(getProfilerRunsDir(), runId);
440
+ }
441
+
442
+ export function ensureDataDir(): void {
443
+ const workspace = getWorkspaceDir();
444
+ const wsData = join(workspace, "data");
445
+ const dirs = [
446
+ // Workspace dirs
447
+ workspace,
448
+ join(workspace, "signals"),
449
+ join(workspace, "skills"),
450
+ join(workspace, "routes"),
451
+ join(workspace, "embedding-models"),
452
+ join(workspace, "conversations"),
453
+ join(workspace, "logs"),
454
+ join(workspace, "external"),
455
+ join(workspace, "bin"),
456
+ // Data sub-dirs under workspace
457
+ wsData,
458
+ join(wsData, "db"),
459
+ join(wsData, "qdrant"),
460
+ join(wsData, "logs"),
461
+ join(wsData, "memory"),
462
+ join(wsData, "memory", "knowledge"),
463
+ join(wsData, "apps"),
464
+ join(wsData, "attachments"),
465
+ join(wsData, "sounds"),
466
+ ];
467
+ for (const dir of dirs) {
468
+ if (!existsSync(dir)) {
469
+ mkdirSync(dir, { recursive: true });
470
+ }
471
+ }
472
+ // Lock down the root directory so only the owner can traverse it.
473
+ // Runtime files (socket, session token, PID) live directly under root.
474
+ try {
475
+ chmodSync(dirname(workspace), 0o700);
476
+ } catch {
477
+ // Non-fatal: some filesystems don't support Unix permissions
478
+ }
479
+ }