@openparachute/vault 0.4.7-rc.1 → 0.4.8-rc.4

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/README.md +44 -10
  2. package/core/src/connection-pragmas.test.ts +232 -0
  3. package/core/src/core.test.ts +257 -0
  4. package/core/src/cursor.test.ts +160 -0
  5. package/core/src/cursor.ts +272 -0
  6. package/core/src/mcp.ts +51 -7
  7. package/core/src/notes.ts +164 -2
  8. package/core/src/portable-md.test.ts +247 -0
  9. package/core/src/portable-md.ts +118 -1
  10. package/core/src/schema.ts +98 -2
  11. package/core/src/store.ts +11 -1
  12. package/core/src/types.ts +32 -0
  13. package/package.json +1 -1
  14. package/src/auth-status.ts +4 -0
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/cli.ts +151 -50
  18. package/src/config.test.ts +26 -0
  19. package/src/config.ts +53 -1
  20. package/src/db.ts +15 -2
  21. package/src/export-watch.test.ts +99 -0
  22. package/src/mcp-install-interactive.test.ts +23 -2
  23. package/src/mcp-install-interactive.ts +21 -2
  24. package/src/mcp-install.test.ts +40 -0
  25. package/src/mcp-tools.ts +17 -1
  26. package/src/module-config.ts +70 -14
  27. package/src/module-manifest.test.ts +93 -0
  28. package/src/module-manifest.ts +94 -0
  29. package/src/routes.ts +267 -50
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +380 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
package/src/cli.ts CHANGED
@@ -103,7 +103,7 @@ import type { TokenPermission } from "./token-store.ts";
103
103
  import { resolveCreateTokenFlags, VAULT_SCOPES } from "./scopes.ts";
104
104
  import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
105
105
  import { getVaultStore } from "./vault-store.ts";
106
- import { upsertService, ServicesManifestError } from "./services-manifest.ts";
106
+ import { selfRegister } from "./self-register.ts";
107
107
  import {
108
108
  hasOwnerPassword,
109
109
  setOwnerPassword,
@@ -245,27 +245,6 @@ switch (command) {
245
245
  // Command implementations
246
246
  // ---------------------------------------------------------------------------
247
247
 
248
- /**
249
- * Compute the `paths` array for the parachute-vault entry in services.json.
250
- * One entry advertises every vault on this server; `paths[0]` is the
251
- * canonical mount the hub stamps into `.well-known/parachute.json`, so the
252
- * default vault sorts first when one is set. With no vaults yet, fall back
253
- * to "/" so an early-init registration is still well-formed.
254
- */
255
- function buildVaultServicePaths(
256
- defaultVault: string | undefined,
257
- vaults: string[],
258
- ): string[] {
259
- if (vaults.length === 0) return ["/"];
260
- if (defaultVault && vaults.includes(defaultVault)) {
261
- return [
262
- `/vault/${defaultVault}`,
263
- ...vaults.filter((v) => v !== defaultVault).map((v) => `/vault/${v}`),
264
- ];
265
- }
266
- return vaults.map((v) => `/vault/${v}`);
267
- }
268
-
269
248
  async function cmdInit(args: string[] = []) {
270
249
  ensureConfigDirSync();
271
250
 
@@ -363,24 +342,24 @@ async function cmdInit(args: string[] = []) {
363
342
  // by name, preserving entries for other services. Non-fatal on failure —
364
343
  // init can complete without the manifest, just with a warning.
365
344
  //
345
+ // `selfRegister` stamps the full manifest-sourced row (displayName, tagline,
346
+ // stripPrefix, installDir) from `.parachute/module.json` — the same shape
347
+ // server boot writes via the self-registration pass (vault#266). Keeping
348
+ // the two write paths in lockstep means `parachute-vault init` and the
349
+ // first server boot agree on the row contents; without that, a re-init
350
+ // would silently lose the manifest fields the boot pass had added.
351
+ //
366
352
  // `paths[0]` is the canonical mount point — the hub uses it for the
367
353
  // `.well-known/parachute.json` URL and for `parachute expose`, so the
368
354
  // default vault always sorts first. Remaining vaults follow so the hub
369
355
  // well-known and paraclaw's attach picker see every vault on this server.
370
356
  // Re-running init re-registers the full set; that doubles as the
371
357
  // recovery path for installs whose services.json is stale (#208).
372
- try {
373
- upsertService({
374
- name: "parachute-vault",
375
- port: globalConfig.port || DEFAULT_PORT,
376
- paths: buildVaultServicePaths(globalConfig.default_vault, allVaults),
377
- health: "/health",
378
- version: pkg.version,
379
- });
380
- } catch (err) {
381
- const msg = err instanceof ServicesManifestError ? err.message : String(err);
382
- console.error(` Warning: could not update ~/.parachute/services.json: ${msg}`);
383
- }
358
+ selfRegister({
359
+ version: pkg.version,
360
+ warn: (msg) => console.error(` Warning: ${msg}`),
361
+ log: () => {}, // CLI init has its own status lines; suppress duplicate noise.
362
+ });
384
363
 
385
364
  // 2b. Migrate existing legacy keys into per-vault token tables
386
365
  for (const v of listVaults()) {
@@ -862,19 +841,16 @@ function cmdCreate(args: string[]) {
862
841
  // attach picker see this vault. cmdInit registers on first run; cmdCreate
863
842
  // adds the new path on every subsequent vault. Without this, vaults
864
843
  // created after init were invisible to the hub (#208).
865
- // Warnings go to stderr to keep --json stdout clean for the orchestrator.
866
- try {
867
- upsertService({
868
- name: "parachute-vault",
869
- port: globalConfig.port || DEFAULT_PORT,
870
- paths: buildVaultServicePaths(globalConfig.default_vault, listVaults()),
871
- health: "/health",
872
- version: pkg.version,
873
- });
874
- } catch (err) {
875
- const msg = err instanceof ServicesManifestError ? err.message : String(err);
876
- console.error(`Warning: could not update ~/.parachute/services.json: ${msg}`);
877
- }
844
+ //
845
+ // Routed through `selfRegister` (vault#266) so the row carries the full
846
+ // manifest-sourced metadata (displayName, tagline, stripPrefix, installDir)
847
+ // — same shape server boot writes. Warnings go to stderr to keep --json
848
+ // stdout clean for the orchestrator.
849
+ selfRegister({
850
+ version: pkg.version,
851
+ warn: (msg) => console.error(`Warning: ${msg}`),
852
+ log: () => {}, // CLI create has its own status lines.
853
+ });
878
854
 
879
855
  if (jsonMode) {
880
856
  const payload = {
@@ -943,6 +919,11 @@ function takeArgValue(args: string[], name: string): { value?: string; missingVa
943
919
  * Targeting:
944
920
  * --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
945
921
  * For --mint, expands to vault:<vault-name>:<verb>.
922
+ * vault:admin requires --legacy-pat — hub policy makes
923
+ * per-vault admin non-requestable via mint-token (it's
924
+ * operator-only, minted only through the session-
925
+ * cookie-gated admin SPA endpoint), so --mint + admin
926
+ * is rejected pre-flight.
946
927
  * --install-scope <s> local (default) | user | project. local writes to
947
928
  * ~/.claude.json under projects[<cwd>].mcpServers
948
929
  * (private, this directory only — matches Claude
@@ -1331,6 +1312,28 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1331
1312
  bearer = fullToken;
1332
1313
  } else {
1333
1314
  // mode === "mint"
1315
+ // Pre-flight: hub policy rejects `vault:<name>:admin` via the public
1316
+ // mint-token endpoint. Per-vault admin is operator-only, mintable
1317
+ // only through the session-cookie-gated `/admin/vault-admin-token/:name`
1318
+ // SPA path. Calling hub with admin would surface a 400:
1319
+ // "Hub mint-token rejected (HTTP 400, invalid_scope):
1320
+ // scope vault:default:admin is not requestable via mint-token;
1321
+ // use OAuth flow or operator rotation"
1322
+ // Fail early with the actionable remediation rather than letting
1323
+ // the operator chase the hub's wire-level error. See
1324
+ // `parachute-hub/src/scope-explanations.ts` (VAULT_ADMIN_RE) and
1325
+ // `parachute-hub/src/api-mint-token.ts` (non-requestable guard).
1326
+ if (verb === "admin") {
1327
+ console.error(
1328
+ "Hub policy: vault:<name>:admin is not requestable via mint-token " +
1329
+ "(per-vault admin is operator-only, minted only by the session-cookie-gated " +
1330
+ "admin SPA at <hub>/admin/vaults/" + vaultName + ").\n" +
1331
+ " Fix: use `--legacy-pat --scope vault:admin` to mint a vault-DB pvt_* with admin scope " +
1332
+ "(the right shape for an MCP entry needing schema management).\n" +
1333
+ " Or: drop --scope to default to vault:read (least privilege), or use --scope vault:write.",
1334
+ );
1335
+ process.exit(1);
1336
+ }
1334
1337
  const operatorToken = readOperatorToken();
1335
1338
  if (!operatorToken) {
1336
1339
  console.error(
@@ -2872,6 +2875,7 @@ async function cmdExport(args: string[]) {
2872
2875
  const { DEFAULT_COMMIT_TEMPLATE } = await import("./export-watch.ts");
2873
2876
  let gitMessageTemplate = DEFAULT_COMMIT_TEMPLATE;
2874
2877
  let gitPush = false;
2878
+ let strictCaseCollision = false;
2875
2879
 
2876
2880
  const positional: string[] = [];
2877
2881
  for (let i = 0; i < args.length; i++) {
@@ -2923,6 +2927,8 @@ async function cmdExport(args: string[]) {
2923
2927
  gitMessageTemplate = v;
2924
2928
  } else if (arg === "--git-push") {
2925
2929
  gitPush = true;
2930
+ } else if (arg === "--strict-case-collision") {
2931
+ strictCaseCollision = true;
2926
2932
  } else {
2927
2933
  positional.push(arg);
2928
2934
  }
@@ -2952,6 +2958,12 @@ async function cmdExport(args: string[]) {
2952
2958
  console.error(" {{first_note_title}}, {{vault_name}}");
2953
2959
  console.error(" (default: \"export: {{date}} ({{notes_changed}} note{{plural}})\")");
2954
2960
  console.error(" --git-push After commit, run `git push` (non-fatal on failure).");
2961
+ console.error(" --strict-case-collision On case-insensitive filesystems (macOS APFS-default,");
2962
+ console.error(" Windows NTFS, FAT/exFAT), abort the export if two notes");
2963
+ console.error(" have paths differing only by case (vault#327).");
2964
+ console.error(" Without this flag, colliding notes are auto-disambiguated");
2965
+ console.error(" with an `__<id-short>` filename suffix (lossless; both");
2966
+ console.error(" files land, canonical path preserved in frontmatter).");
2955
2967
  process.exit(1);
2956
2968
  }
2957
2969
 
@@ -3010,6 +3022,7 @@ async function cmdExport(args: string[]) {
3010
3022
  assetsDir: assetsDirPath,
3011
3023
  ...(vaultDescription ? { vaultDescription } : {}),
3012
3024
  ...(opts.sinceCursor ? { since: opts.sinceCursor } : {}),
3025
+ ...(strictCaseCollision ? { failOnCaseCollision: true } : {}),
3013
3026
  });
3014
3027
 
3015
3028
  if (opts.isInitial) {
@@ -3030,6 +3043,28 @@ async function cmdExport(args: string[]) {
3030
3043
  `Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`,
3031
3044
  );
3032
3045
  }
3046
+ // vault#327 Phase 2 — surface case-collision auto-disambiguation
3047
+ // explicitly. The previous fix landed the logic silently; the
3048
+ // operator had no signal that filenames had been munged. Now we
3049
+ // print the count + every disambiguated path so the operator can
3050
+ // (a) audit, (b) decide whether to rename a colliding note in
3051
+ // the vault and re-export, or (c) re-run with
3052
+ // --strict-case-collision to refuse the disambiguation entirely.
3053
+ if (stats.disambiguated_paths.length > 0) {
3054
+ const n = stats.disambiguated_paths.length;
3055
+ console.warn(
3056
+ `Warning: ${n} note${n === 1 ? "" : "s"} had path${n === 1 ? "" : "s"} that ` +
3057
+ `differ only by case on this case-insensitive filesystem. ` +
3058
+ `Auto-disambiguated on disk (canonical paths preserved in frontmatter):`,
3059
+ );
3060
+ for (const d of stats.disambiguated_paths) {
3061
+ console.warn(` - ${d.original_path} → ${d.disambiguated_filename} (id: ${d.note_id})`);
3062
+ }
3063
+ console.warn(
3064
+ `Re-run with --strict-case-collision to abort instead, or rename one of each ` +
3065
+ `colliding pair in the vault before re-exporting. See vault#327.`,
3066
+ );
3067
+ }
3033
3068
  } else {
3034
3069
  // Watch-mode status line: keep tight; the loop logs every interval.
3035
3070
  if (stats.notes > 0) {
@@ -3039,6 +3074,17 @@ async function cmdExport(args: string[]) {
3039
3074
  } else {
3040
3075
  console.log(`[watch] no changes`);
3041
3076
  }
3077
+ // Watch-mode: log new disambiguations per cycle. Operators running
3078
+ // a long-lived watch loop want to be notified the moment a
3079
+ // collision shows up — they can fix it at the source without
3080
+ // waiting for the next manual full export.
3081
+ if (stats.disambiguated_paths.length > 0) {
3082
+ const n = stats.disambiguated_paths.length;
3083
+ console.warn(
3084
+ `[watch] case-collision: ${n} disambiguated path${n === 1 ? "" : "s"} this cycle ` +
3085
+ `(see vault#327; --strict-case-collision to abort instead).`,
3086
+ );
3087
+ }
3042
3088
  }
3043
3089
 
3044
3090
  let committed = false;
@@ -3058,15 +3104,44 @@ async function cmdExport(args: string[]) {
3058
3104
  return { stats, nextCursor, committed };
3059
3105
  }
3060
3106
 
3107
+ // Import the typed CaseCollisionError once so both the single-shot and
3108
+ // watch-initial paths can render it the same way. vault#327 Phase 2.
3109
+ const { CaseCollisionError } = await import("../core/src/portable-md.ts");
3110
+
3061
3111
  // ---- Single-shot mode ----
3062
3112
  if (!watch) {
3063
- await runCycle({ sinceCursor: since, isInitial: true });
3113
+ try {
3114
+ await runCycle({ sinceCursor: since, isInitial: true });
3115
+ } catch (err) {
3116
+ if (err instanceof CaseCollisionError) {
3117
+ // The error's own message already includes the actionable
3118
+ // instruction ("Rename one of them..."). Print verbatim + exit
3119
+ // non-zero so scripts catch the failure deterministically.
3120
+ console.error(err.message);
3121
+ process.exit(1);
3122
+ }
3123
+ throw err;
3124
+ }
3064
3125
  return;
3065
3126
  }
3066
3127
 
3067
3128
  // ---- Watch mode ----
3068
3129
  // Initial full (or since-filtered) export, then poll every interval.
3069
- const initial = await runCycle({ sinceCursor: since, isInitial: true });
3130
+ let initial: Awaited<ReturnType<typeof runCycle>>;
3131
+ try {
3132
+ initial = await runCycle({ sinceCursor: since, isInitial: true });
3133
+ } catch (err) {
3134
+ if (err instanceof CaseCollisionError) {
3135
+ console.error(err.message);
3136
+ console.error(
3137
+ "\n--strict-case-collision is enabled; refusing to start the watch loop until the " +
3138
+ "collision is resolved in the vault. Re-export without --strict-case-collision to " +
3139
+ "auto-disambiguate instead.",
3140
+ );
3141
+ process.exit(1);
3142
+ }
3143
+ throw err;
3144
+ }
3070
3145
  let cursor = initial.nextCursor;
3071
3146
  console.log(`[watch] polling every ${intervalSeconds}s; press Ctrl-C to stop.`);
3072
3147
 
@@ -3095,6 +3170,26 @@ async function cmdExport(args: string[]) {
3095
3170
  const cycle = await runCycle({ sinceCursor: cursor, isInitial: false });
3096
3171
  cursor = cycle.nextCursor;
3097
3172
  } catch (err) {
3173
+ // CaseCollisionError under --strict-case-collision means the
3174
+ // operator opted into "abort on any collision". A new collision
3175
+ // that appears mid-watch (e.g. an LLM client just wrote
3176
+ // `Inbox/Foo` to a vault that already had `Inbox/foo`) must NOT
3177
+ // be swallowed into the generic [watch] export-error log — the
3178
+ // strict-mode guarantee would silently degrade after the initial
3179
+ // export. Print the full collision message + actionable hint and
3180
+ // stop the loop via the same SIGINT-style pathway used by Ctrl-C
3181
+ // (clears timer, gives in-flight work a 250ms settle window).
3182
+ // Exits non-zero so supervisors / git-watch sidecars catch it.
3183
+ if (err instanceof CaseCollisionError) {
3184
+ console.error(err.message);
3185
+ console.error(
3186
+ "Resolve the collision in the vault or re-run without --strict-case-collision to fall back to auto-disambiguate mode.",
3187
+ );
3188
+ stopping = true;
3189
+ if (timer) clearInterval(timer);
3190
+ setTimeout(() => process.exit(1), 0);
3191
+ return;
3192
+ }
3098
3193
  // Don't kill the loop on a transient export error — log and keep
3099
3194
  // polling. Operator can Ctrl-C if they want to bail.
3100
3195
  console.error(`[watch] export error: ${(err as Error).message ?? err}`);
@@ -3300,7 +3395,13 @@ Vaults:
3300
3395
  (any shape) instead of minting.
3301
3396
  --legacy-pat: mint a vault-DB pvt_*
3302
3397
  token (deprecated; for self-hosted-
3303
- without-hub setups).
3398
+ without-hub setups). Also the only
3399
+ path for --scope vault:admin — hub
3400
+ policy reserves per-vault admin for
3401
+ operator-only minting (the admin SPA
3402
+ session-cookie path), so --mint
3403
+ --scope vault:admin is rejected
3404
+ pre-flight.
3304
3405
  --install-scope local (default) writes
3305
3406
  ~/.claude.json under
3306
3407
  projects[<cwd>].mcpServers (this
@@ -1,9 +1,12 @@
1
1
  import { describe, test, expect } from "bun:test";
2
+ import { statSync } from "fs";
3
+ import { join } from "path";
2
4
  import {
3
5
  writeVaultConfig,
4
6
  readVaultConfig,
5
7
  writeGlobalConfig,
6
8
  readGlobalConfig,
9
+ writeEnvFile,
7
10
  generateApiKey,
8
11
  hashKey,
9
12
  verifyKey,
@@ -210,6 +213,29 @@ describe("config", () => {
210
213
  expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
211
214
  });
212
215
 
216
+ test("writeEnvFile writes .env at 0600 (SCRIBE_AUTH_TOKEN secrecy)", () => {
217
+ // Regression for vault#354 reviewer finding: the .env holds
218
+ // SCRIBE_AUTH_TOKEN (the vault↔scribe loopback bearer). On a
219
+ // shared-user machine or a Docker image with a loose umask, a
220
+ // world-readable .env would leak the bearer to any local process.
221
+ //
222
+ // Resolve the path dynamically from PARACHUTE_HOME (not the
223
+ // module-load-time `ENV_PATH` constant) so this test is robust to
224
+ // earlier tests in the suite that mutate PARACHUTE_HOME — writeEnvFile
225
+ // itself resolves the path dynamically via `envFilePath()`.
226
+ const envPath = join(process.env.PARACHUTE_HOME!, "vault", ".env");
227
+ writeEnvFile({ SCRIBE_AUTH_TOKEN: "test-bearer-do-not-leak", PORT: "1940" });
228
+ const mode = statSync(envPath).mode & 0o777;
229
+ expect(mode).toBe(0o600);
230
+
231
+ // Existing-file branch: writeFileSync's `mode` only applies on
232
+ // create, so the defensive chmodSync must downgrade an existing
233
+ // (e.g. 0644-from-an-older-version) .env to 0600 on the next write.
234
+ writeEnvFile({ SCRIBE_AUTH_TOKEN: "rotated", PORT: "1940" });
235
+ const mode2 = statSync(envPath).mode & 0o777;
236
+ expect(mode2).toBe(0o600);
237
+ });
238
+
213
239
  test("round-trips autostart: true|false (#113)", () => {
214
240
  // Default: absent means autostart-on (init registers the daemon).
215
241
  writeGlobalConfig({ port: 1940 });
package/src/config.ts CHANGED
@@ -277,6 +277,24 @@ export interface GlobalConfig {
277
277
  * resolved path. See `./mirror-config.ts`.
278
278
  */
279
279
  mirror?: MirrorConfigType;
280
+ /**
281
+ * Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
282
+ * design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
283
+ * (`services.json` or `SCRIBE_URL` env), audio attachments uploaded to any
284
+ * vault are automatically sent to scribe and the resulting transcript lands
285
+ * as a sibling `<attachment-path>.transcript.md` note.
286
+ *
287
+ * URL + bearer are not stored here — URL is resolved per-process from
288
+ * `services.json` via `scribe-discovery.ts`, and bearer comes from the
289
+ * `SCRIBE_AUTH_TOKEN` env var (persisted in `~/.parachute/vault/.env`).
290
+ * The schema's `autoTranscribe.scribeUrl` / `autoTranscribe.scribeBearer`
291
+ * fields are projection of those resolved values (readOnly / writeOnly),
292
+ * not separate storage.
293
+ */
294
+ auto_transcribe?: {
295
+ /** Master toggle. Default false; the worker is a no-op when unset. */
296
+ enabled?: boolean;
297
+ };
280
298
  }
281
299
 
282
300
  // ---------------------------------------------------------------------------
@@ -1141,6 +1159,22 @@ export function readGlobalConfig(): GlobalConfig {
1141
1159
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1142
1160
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1143
1161
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1162
+ // auto_transcribe block — currently single boolean `enabled` (vault#353).
1163
+ // Parsed as a nested 2-space-indent block so future fields can grow under
1164
+ // it without breaking the regex; only `enabled` is read for v0.6.
1165
+ const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
1166
+ let autoTranscribeEnabled: boolean | undefined;
1167
+ if (autoTranscribeStart) {
1168
+ const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
1169
+ for (const line of after.split("\n")) {
1170
+ if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
1171
+ const m = line.match(/^\s+enabled:\s*(true|false)/);
1172
+ if (m) {
1173
+ autoTranscribeEnabled = m[1]! === "true";
1174
+ break;
1175
+ }
1176
+ }
1177
+ }
1144
1178
  const config: GlobalConfig = {
1145
1179
  port: portMatch ? parseInt(portMatch[1]!, 10) : DEFAULT_PORT,
1146
1180
  default_vault: defaultVaultMatch?.[1],
@@ -1153,6 +1187,9 @@ export function readGlobalConfig(): GlobalConfig {
1153
1187
  if (autostartMatch) {
1154
1188
  config.autostart = autostartMatch[1]! === "true";
1155
1189
  }
1190
+ if (autoTranscribeEnabled !== undefined) {
1191
+ config.auto_transcribe = { enabled: autoTranscribeEnabled };
1192
+ }
1156
1193
 
1157
1194
  // Parse backup_codes: a YAML list of quoted bcrypt hashes under
1158
1195
  // backup_codes:
@@ -1297,6 +1334,13 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1297
1334
  lines.push(...serializeMirrorSection(config.mirror));
1298
1335
  }
1299
1336
 
1337
+ if (config.auto_transcribe) {
1338
+ lines.push("auto_transcribe:");
1339
+ if (config.auto_transcribe.enabled !== undefined) {
1340
+ lines.push(` enabled: ${config.auto_transcribe.enabled}`);
1341
+ }
1342
+ }
1343
+
1300
1344
  // 0600 — owner read/write only. This file may contain the bcrypt password
1301
1345
  // hash and plaintext TOTP secret; it must not be world- or group-readable.
1302
1346
  writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
@@ -1395,7 +1439,15 @@ export function writeEnvFile(env: Record<string, string>): void {
1395
1439
  lines.push(`${key}=${val}`);
1396
1440
  }
1397
1441
  }
1398
- writeFileSync(envFilePath(), lines.join("\n") + "\n");
1442
+ // 0600 — owner read/write only. This file holds SCRIBE_AUTH_TOKEN (the
1443
+ // vault↔scribe loopback bearer) and any other secrets the operator drops
1444
+ // in via `parachute-vault config set`. It must not be world- or
1445
+ // group-readable on shared-user machines or Docker images with a loose
1446
+ // umask. Mirrors the writeGlobalConfig pattern above.
1447
+ writeFileSync(envFilePath(), lines.join("\n") + "\n", { mode: 0o600 });
1448
+ // writeFileSync's `mode` only applies on file creation, so chmod an existing
1449
+ // file explicitly in case it was written by an older version at 0644.
1450
+ try { chmodSync(envFilePath(), 0o600); } catch {}
1399
1451
  }
1400
1452
 
1401
1453
  /**
package/src/db.ts CHANGED
@@ -4,11 +4,24 @@
4
4
 
5
5
  import { Database } from "bun:sqlite";
6
6
  import { vaultDbPath, vaultDir } from "./config.ts";
7
+ import { applyConnectionPragmas } from "../core/src/schema.ts";
7
8
  import { mkdirSync } from "fs";
8
9
 
9
- /** Open (or create) a vault's SQLite database. */
10
+ /**
11
+ * Open (or create) a vault's SQLite database with vault's standard
12
+ * connection pragmas (WAL + synchronous=NORMAL + foreign_keys=ON). Safe
13
+ * for both the in-process store and out-of-process consumers (CLI,
14
+ * parachute-runner, mirror tools) — pragmas are idempotent.
15
+ *
16
+ * The full schema migration runs separately when a `BunSqliteStore` is
17
+ * instantiated around the returned handle; callers that just want raw
18
+ * read access (auth probes, backup tooling) skip that and pay only the
19
+ * pragma cost.
20
+ */
10
21
  export function openVaultDb(name: string): Database {
11
22
  const dir = vaultDir(name);
12
23
  mkdirSync(dir, { recursive: true });
13
- return new Database(vaultDbPath(name));
24
+ const db = new Database(vaultDbPath(name));
25
+ applyConnectionPragmas(db);
26
+ return db;
14
27
  }
@@ -763,6 +763,105 @@ describe("export CLI: --watch", () => {
763
763
  30_000,
764
764
  );
765
765
 
766
+ test(
767
+ "--strict-case-collision: mid-watch collision stops the loop with the full error + hint",
768
+ async () => {
769
+ // vault#350 reviewer fix. Before: the watch polling timer's
770
+ // generic catch swallowed a CaseCollisionError thrown by a
771
+ // post-initial-export poll, logging only `[watch] export error:
772
+ // ...` and continuing to spin. The strict-mode guarantee
773
+ // ("refuse to continue when a collision appears") evaporated
774
+ // after the initial export.
775
+ //
776
+ // Scenario: start --watch --strict-case-collision against a
777
+ // vault whose initial state has no collisions (so the watch
778
+ // loop boots). Write a colliding note out-of-band. On the next
779
+ // poll cycle, runCycle throws CaseCollisionError; the watch
780
+ // catch path should now (a) print the full err.message to
781
+ // stderr including every colliding path, (b) print the
782
+ // actionable hint, (c) exit non-zero.
783
+ //
784
+ // The CLI doesn't expose `caseSensitiveOverride`, so this test
785
+ // is meaningful only on a case-insensitive FS (macOS APFS
786
+ // default, Windows NTFS default). On a case-sensitive Linux
787
+ // ext4, the pre-scan is a no-op by design and the path under
788
+ // test never fires — skip rather than assert a behavior that
789
+ // can't manifest there.
790
+ const { probeCaseSensitive } = await import("../core/src/portable-md.ts");
791
+ if (probeCaseSensitive(exportDir)) {
792
+ console.log(
793
+ "skipping mid-watch strict-collision test on case-sensitive FS — the strict pre-scan is a no-op here",
794
+ );
795
+ return;
796
+ }
797
+ const watch = spawnWatchCli(
798
+ [
799
+ "export",
800
+ exportDir,
801
+ "--watch",
802
+ "--interval",
803
+ "1",
804
+ "--strict-case-collision",
805
+ ],
806
+ tmp,
807
+ );
808
+ try {
809
+ // Initial export must succeed (seed has no collision).
810
+ await watch.awaitLine((l) => l.includes("Exported 1 note"), 10_000);
811
+ await watch.awaitLine((l) => l.includes("[watch] polling every 1s"), 5_000);
812
+
813
+ // Inject a collision: existing seed is `Inbox/seed`; write
814
+ // `Inbox/SEED` so the lowercased (path, ext) key collides.
815
+ const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
816
+ clearVaultStoreCache();
817
+ const store = getVaultStore("default");
818
+ await store.createNote("# upper\n", {
819
+ id: "01HZB222222222222222222222",
820
+ path: "Inbox/SEED",
821
+ });
822
+ clearVaultStoreCache();
823
+ } catch (err) {
824
+ watch.proc.kill("SIGKILL");
825
+ throw err;
826
+ }
827
+
828
+ // The strict-mode catch path exits the process. Wait for it.
829
+ // Use a guarded race so a hung process doesn't eat the full
830
+ // 30s suite budget — surface a useful failure message instead.
831
+ const exit = await Promise.race([
832
+ watch.proc.exited,
833
+ new Promise<number>((_, reject) =>
834
+ setTimeout(
835
+ () =>
836
+ reject(
837
+ new Error(
838
+ `CLI did not exit within 15s of collision injection.\n` +
839
+ `stdout:\n${watch.seenLines.join("\n")}\n` +
840
+ `stderr:\n${watch.seenStderr.join("\n")}`,
841
+ ),
842
+ ),
843
+ 15_000,
844
+ ),
845
+ ),
846
+ ]).catch((err) => {
847
+ watch.proc.kill("SIGKILL");
848
+ throw err;
849
+ });
850
+ // Non-zero exit: strict mode refused to continue.
851
+ expect(exit).not.toBe(0);
852
+
853
+ const stderr = watch.seenStderr.join("\n");
854
+ // Full collision message: header line + every colliding path.
855
+ expect(stderr).toContain("case-collision detected");
856
+ expect(stderr).toContain("Inbox/seed.md");
857
+ expect(stderr).toContain("Inbox/SEED.md");
858
+ // Actionable hint (the new line added by this fix).
859
+ expect(stderr).toContain("Resolve the collision in the vault");
860
+ expect(stderr).toContain("--strict-case-collision");
861
+ },
862
+ 30_000,
863
+ );
864
+
766
865
  test(
767
866
  "--watch + --git-commit: vault write → re-export → auto-commit → continues",
768
867
  async () => {
@@ -287,8 +287,23 @@ describe("runInteractiveInstall — decision tree", () => {
287
287
  expect(result.scope).toBe("vault:write");
288
288
  });
289
289
 
290
- test("scope widening: typing 'admin' produces vault:admin mint", async () => {
291
- const { io } = mockIO([
290
+ test("typing 'admin' auto-routes to legacy-pat with vault:admin scope (hub mint-token rejects admin)", async () => {
291
+ // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
292
+ // 0.4.7-rc.1: picking "admin" in the mint prompt sent
293
+ // `vault:default:admin` to `POST /api/auth/mint-token`, which hub
294
+ // rejects by policy (per-vault admin is non-requestable; see
295
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
296
+ // `api-mint-token.ts`'s non-requestable guard):
297
+ //
298
+ // Hub mint-token rejected (HTTP 400, invalid_scope):
299
+ // scope vault:default:admin is not requestable via mint-token;
300
+ // use OAuth flow or operator rotation
301
+ //
302
+ // Fix: auto-route "admin" in the interactive prompt to legacy-pat
303
+ // mode (which mints a vault-DB pvt_* — the right shape for an MCP
304
+ // entry needing admin permissions), with a printed explanation so
305
+ // the switch isn't silent.
306
+ const { io, state } = mockIO([
292
307
  null, // accept install-scope default
293
308
  "admin",
294
309
  true,
@@ -296,7 +311,13 @@ describe("runInteractiveInstall — decision tree", () => {
296
311
  const result = await runInteractiveInstall(baseCtx(), io);
297
312
  expect(result).not.toBe("abort");
298
313
  if (result === "abort") return;
314
+ expect(result.mode).toBe("legacy-pat");
299
315
  expect(result.scope).toBe("vault:admin");
316
+ // The auto-route must surface the reason — silent re-routing would
317
+ // mislead operators who specifically want a hub JWT.
318
+ const logged = state.logs.join("\n");
319
+ expect(logged).toMatch(/admin requires a vault-DB pvt_\*/);
320
+ expect(logged).toMatch(/hub policy/);
300
321
  });
301
322
 
302
323
  test("typing 'paste' at the auth prompt switches to token mode + asks for token", async () => {
@@ -190,7 +190,9 @@ export async function runInteractiveInstall(
190
190
  "Choices:",
191
191
  " Enter → mint a hub JWT with vault:read scope (recommended).",
192
192
  " write → mint with vault:write (mutations).",
193
- " admin → mint with vault:admin (schema management).",
193
+ " admin → mint a vault-DB pvt_* with vault:admin (schema management;",
194
+ " hub policy reserves per-vault admin for operator-only paths,",
195
+ " so this auto-routes to legacy-pat).",
194
196
  " paste → use an existing token instead of minting.",
195
197
  " legacy → mint a vault-DB pvt_* (self-hosted-without-hub).",
196
198
  ].join("\n"),
@@ -209,10 +211,27 @@ export async function runInteractiveInstall(
209
211
  // operator gets the same control they get when widening a hub
210
212
  // JWT's scope. (vault#292 review F2.)
211
213
  scope = await askScope(io);
214
+ } else if (answer === "admin") {
215
+ // `vault:<name>:admin` is non-requestable via hub mint-token by
216
+ // policy — only the session-cookie-gated `/admin/vault-admin-token/:name`
217
+ // endpoint can mint per-vault admin scopes (see
218
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
219
+ // `api-mint-token.ts`'s non-requestable guard). Hub returns
220
+ // HTTP 400 invalid_scope: "scope vault:<name>:admin is not
221
+ // requestable via mint-token; use OAuth flow or operator rotation".
222
+ //
223
+ // Auto-route to legacy-pat which mints a vault-DB pvt_* with full
224
+ // permissions — that's the right shape for an operator who wants
225
+ // admin scope on a local MCP entry. Print a one-line explanation
226
+ // so the switch isn't silent.
227
+ io.log(" → admin requires a vault-DB pvt_* (hub policy: per-vault admin");
228
+ io.log(" is operator-only, not mintable via the public mint-token API).");
229
+ io.log(" Switching to legacy-pat mode with vault:admin scope.");
230
+ mode = "legacy-pat";
231
+ scope = "vault:admin";
212
232
  } else {
213
233
  mode = "mint";
214
234
  if (answer === "write") scope = "vault:write";
215
- else if (answer === "admin") scope = "vault:admin";
216
235
  }
217
236
  } else {
218
237
  // No hub-mint path available — explain why and offer the alternatives.