@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.10

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 (54) hide show
  1. package/.parachute/module.json +1 -1
  2. package/README.md +78 -41
  3. package/core/src/connection-pragmas.test.ts +232 -0
  4. package/core/src/core.test.ts +257 -0
  5. package/core/src/cursor.test.ts +160 -0
  6. package/core/src/cursor.ts +272 -0
  7. package/core/src/mcp.ts +51 -7
  8. package/core/src/notes.ts +164 -2
  9. package/core/src/schema.ts +106 -5
  10. package/core/src/store.ts +11 -1
  11. package/core/src/types.ts +32 -0
  12. package/package.json +7 -3
  13. package/src/auth-status.ts +4 -0
  14. package/src/auth.test.ts +5 -112
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/backup.ts +17 -3
  18. package/src/cli.ts +95 -66
  19. package/src/config.test.ts +26 -0
  20. package/src/config.ts +53 -1
  21. package/src/db.ts +15 -2
  22. package/src/export-watch.test.ts +21 -0
  23. package/src/mcp-install-interactive.test.ts +23 -2
  24. package/src/mcp-install-interactive.ts +21 -2
  25. package/src/mcp-install.test.ts +40 -0
  26. package/src/mcp-tools.ts +17 -1
  27. package/src/module-config.ts +70 -14
  28. package/src/module-manifest.test.ts +114 -0
  29. package/src/module-manifest.ts +104 -0
  30. package/src/oauth-discovery.ts +95 -0
  31. package/src/owner-auth.ts +22 -149
  32. package/src/routes.ts +268 -51
  33. package/src/routing.test.ts +102 -99
  34. package/src/routing.ts +33 -47
  35. package/src/scribe-discovery.test.ts +77 -0
  36. package/src/scribe-discovery.ts +91 -0
  37. package/src/scribe-env.test.ts +66 -1
  38. package/src/scribe-env.ts +42 -1
  39. package/src/self-register.test.ts +412 -0
  40. package/src/self-register.ts +247 -0
  41. package/src/server.ts +47 -23
  42. package/src/transcript-note.test.ts +171 -0
  43. package/src/transcript-note.ts +189 -0
  44. package/src/transcription-registry.ts +22 -0
  45. package/src/transcription-worker.test.ts +250 -0
  46. package/src/transcription-worker.ts +186 -27
  47. package/src/vault-name.ts +3 -2
  48. package/src/vault.test.ts +347 -0
  49. package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
  50. package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
  51. package/web/ui/dist/index.html +14 -0
  52. package/web/ui/tsconfig.json +21 -0
  53. package/src/oauth.test.ts +0 -2156
  54. package/src/oauth.ts +0 -973
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()) {
@@ -409,17 +388,11 @@ async function cmdInit(args: string[] = []) {
409
388
  console.log();
410
389
  }
411
390
 
412
- // 5b. Owner password is only needed for OAuth consent (browser-based
413
- // clients like claude.ai / ChatGPT / Claude Desktop). Those paths are
414
- // coming in the next few weeks; until then, skip the prompt. Users who
415
- // want to expose the vault publicly today can set one manually via
416
- // `parachute-vault set-password`.
417
- if (!hasOwnerPassword()) {
418
- console.log();
419
- console.log("Public exposure + web-AI connectors (claude.ai, ChatGPT, etc.) are coming soon.");
420
- console.log(" When you're ready to expose this vault publicly, run:");
421
- console.log(" parachute-vault set-password # required for OAuth consent");
422
- }
391
+ // 5b. OAuth consent now runs on the hub (workstream E, 2026-05-25). Vault
392
+ // no longer renders its own consent page, so no owner-password prompt
393
+ // belongs in `vault init`. Operators who want to expose vault publicly to
394
+ // browser-based clients should run `parachute install hub`; the hub owns
395
+ // the consent surface, the sign-in flow, and the JWT issuance.
423
396
 
424
397
  // 6. Install daemon (platform-aware). Idempotent — safe to re-run after
425
398
  // a folder move; this refreshes ~/.parachute/server-path and bounces the
@@ -575,8 +548,8 @@ async function promptVaultName(): Promise<string> {
575
548
 
576
549
  async function promptForOwnerPassword(purpose: string): Promise<boolean> {
577
550
  console.log(`\n${purpose}`);
578
- console.log(" Used on the OAuth consent page to authorize third-party clients");
579
- console.log(" (Claude Web, Claude Desktop, etc.) to access this vault.");
551
+ console.log(" Legacy field vault's standalone OAuth consent was retired in 0.4.x.");
552
+ console.log(" Stored in config.yaml for hub's expose-posture-check only.");
580
553
  console.log(` Minimum 12 characters.\n`);
581
554
 
582
555
  while (true) {
@@ -605,6 +578,16 @@ async function promptForOwnerPassword(purpose: string): Promise<boolean> {
605
578
  }
606
579
 
607
580
  async function cmdSetPassword(args: string[]) {
581
+ // Legacy command (workstream E, 2026-05-25). Vault's standalone OAuth
582
+ // consent page was retired; this command now only writes the
583
+ // `owner_password_hash` field that hub's `expose public` posture-check
584
+ // reads. It no longer gates any auth flow inside vault. Set hub
585
+ // credentials with `parachute auth set-password`.
586
+ console.warn(
587
+ "[deprecated] vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub.\n" +
588
+ " This command writes a legacy YAML field but no longer gates auth inside vault.\n" +
589
+ " Set hub credentials with `parachute auth set-password`.\n",
590
+ );
608
591
  const wantsClear = args.includes("--clear") || args.includes("--unset");
609
592
  if (wantsClear) {
610
593
  if (!hasOwnerPassword()) {
@@ -615,7 +598,7 @@ async function cmdSetPassword(args: string[]) {
615
598
  ? " Note: 2FA management operations will require your authenticator app or a backup code instead."
616
599
  : "";
617
600
  const ok = await confirm(
618
- `Remove the owner password? OAuth consent will fall back to vault-token auth.${twoFaNote}`,
601
+ `Remove the owner password? (Legacy field — vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub now.)${twoFaNote}`,
619
602
  false,
620
603
  );
621
604
  if (!ok) {
@@ -696,6 +679,16 @@ async function confirmForTwoFactor(purpose: string): Promise<boolean> {
696
679
  }
697
680
 
698
681
  async function cmd2fa(args: string[]) {
682
+ // Legacy command (workstream E, 2026-05-25). See cmdSetPassword for the
683
+ // full deprecation story. 2FA on vault used to layer on top of the
684
+ // owner-password gate for the standalone OAuth consent page; both have
685
+ // been retired. The CLI still manages the legacy YAML fields for
686
+ // back-compat.
687
+ console.warn(
688
+ "[deprecated] vault's standalone OAuth consent was retired in 0.4.x; OAuth runs on the hub.\n" +
689
+ " This command writes legacy YAML fields but no longer gates auth inside vault.\n",
690
+ );
691
+
699
692
  const sub = args[0] ?? "status";
700
693
 
701
694
  if (sub === "status") {
@@ -763,7 +756,7 @@ async function cmd2fa(args: string[]) {
763
756
  for (const code of result.backupCodes) {
764
757
  console.log(` ${code}`);
765
758
  }
766
- console.log("\n2FA is now active for OAuth consent on this vault.");
759
+ console.log("\n2FA is now recorded in the legacy YAML field. (See deprecation note above.)");
767
760
  return;
768
761
  }
769
762
 
@@ -862,19 +855,16 @@ function cmdCreate(args: string[]) {
862
855
  // attach picker see this vault. cmdInit registers on first run; cmdCreate
863
856
  // adds the new path on every subsequent vault. Without this, vaults
864
857
  // 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
- }
858
+ //
859
+ // Routed through `selfRegister` (vault#266) so the row carries the full
860
+ // manifest-sourced metadata (displayName, tagline, stripPrefix, installDir)
861
+ // — same shape server boot writes. Warnings go to stderr to keep --json
862
+ // stdout clean for the orchestrator.
863
+ selfRegister({
864
+ version: pkg.version,
865
+ warn: (msg) => console.error(`Warning: ${msg}`),
866
+ log: () => {}, // CLI create has its own status lines.
867
+ });
878
868
 
879
869
  if (jsonMode) {
880
870
  const payload = {
@@ -943,6 +933,11 @@ function takeArgValue(args: string[], name: string): { value?: string; missingVa
943
933
  * Targeting:
944
934
  * --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
945
935
  * For --mint, expands to vault:<vault-name>:<verb>.
936
+ * vault:admin requires --legacy-pat — hub policy makes
937
+ * per-vault admin non-requestable via mint-token (it's
938
+ * operator-only, minted only through the session-
939
+ * cookie-gated admin SPA endpoint), so --mint + admin
940
+ * is rejected pre-flight.
946
941
  * --install-scope <s> local (default) | user | project. local writes to
947
942
  * ~/.claude.json under projects[<cwd>].mcpServers
948
943
  * (private, this directory only — matches Claude
@@ -1331,6 +1326,28 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
1331
1326
  bearer = fullToken;
1332
1327
  } else {
1333
1328
  // mode === "mint"
1329
+ // Pre-flight: hub policy rejects `vault:<name>:admin` via the public
1330
+ // mint-token endpoint. Per-vault admin is operator-only, mintable
1331
+ // only through the session-cookie-gated `/admin/vault-admin-token/:name`
1332
+ // SPA path. Calling hub with admin would surface a 400:
1333
+ // "Hub mint-token rejected (HTTP 400, invalid_scope):
1334
+ // scope vault:default:admin is not requestable via mint-token;
1335
+ // use OAuth flow or operator rotation"
1336
+ // Fail early with the actionable remediation rather than letting
1337
+ // the operator chase the hub's wire-level error. See
1338
+ // `parachute-hub/src/scope-explanations.ts` (VAULT_ADMIN_RE) and
1339
+ // `parachute-hub/src/api-mint-token.ts` (non-requestable guard).
1340
+ if (verb === "admin") {
1341
+ console.error(
1342
+ "Hub policy: vault:<name>:admin is not requestable via mint-token " +
1343
+ "(per-vault admin is operator-only, minted only by the session-cookie-gated " +
1344
+ "admin SPA at <hub>/admin/vaults/" + vaultName + ").\n" +
1345
+ " Fix: use `--legacy-pat --scope vault:admin` to mint a vault-DB pvt_* with admin scope " +
1346
+ "(the right shape for an MCP entry needing schema management).\n" +
1347
+ " Or: drop --scope to default to vault:read (least privilege), or use --scope vault:write.",
1348
+ );
1349
+ process.exit(1);
1350
+ }
1334
1351
  const operatorToken = readOperatorToken();
1335
1352
  if (!operatorToken) {
1336
1353
  console.error(
@@ -3392,7 +3409,13 @@ Vaults:
3392
3409
  (any shape) instead of minting.
3393
3410
  --legacy-pat: mint a vault-DB pvt_*
3394
3411
  token (deprecated; for self-hosted-
3395
- without-hub setups).
3412
+ without-hub setups). Also the only
3413
+ path for --scope vault:admin — hub
3414
+ policy reserves per-vault admin for
3415
+ operator-only minting (the admin SPA
3416
+ session-cookie path), so --mint
3417
+ --scope vault:admin is rejected
3418
+ pre-flight.
3396
3419
  --install-scope local (default) writes
3397
3420
  ~/.claude.json under
3398
3421
  projects[<cwd>].mcpServers (this
@@ -3449,12 +3472,18 @@ Tokens:
3449
3472
  parachute-vault tokens create --expires 30d Expiring token
3450
3473
  parachute-vault tokens revoke <token-id> Revoke a token (default vault)
3451
3474
 
3452
- OAuth:
3453
- parachute-vault set-password Set/change the owner password (for consent page)
3475
+ OAuth — owner password + 2FA (LEGACY):
3476
+ Vault's standalone OAuth consent page was retired in 0.4.x (workstream E).
3477
+ OAuth runs on the hub now. These commands still write the legacy YAML
3478
+ fields (hub's \`expose public\` posture-check reads them), but they
3479
+ don't gate any consent flow inside vault. Set hub credentials with
3480
+ \`parachute auth set-password\`.
3481
+
3482
+ parachute-vault set-password Set/change owner password (legacy YAML field)
3454
3483
  parachute-vault set-password --clear Remove the owner password
3455
3484
  parachute-vault 2fa status Show 2FA state
3456
3485
  parachute-vault 2fa enroll Enable TOTP 2FA (QR + backup codes)
3457
- parachute-vault 2fa disable Disable 2FA (requires password)
3486
+ parachute-vault 2fa disable Disable 2FA
3458
3487
  parachute-vault 2fa backup-codes Regenerate backup codes
3459
3488
 
3460
3489
  Config:
@@ -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
  }
@@ -37,6 +37,25 @@ import {
37
37
 
38
38
  const CLI = path.resolve(import.meta.dir, "cli.ts");
39
39
 
40
+ // Capture HOME / PARACHUTE_HOME at module load so `seedVaultWithNotes`'s
41
+ // in-process env mutation (required for the deferred `vault-store.ts`
42
+ // re-import to see PARACHUTE_HOME) can be reverted between tests. Without
43
+ // this, the polluted HOME leaks into later tests in the same process — most
44
+ // visibly into `resolveInstallTarget` (mcp-install.test.ts), which reads
45
+ // `process.env.HOME` directly and would otherwise return paths under the
46
+ // tmpdir. Linux-only failure because macOS BSD developers were less likely
47
+ // to notice in dev; CI on Linux exposed it after the tag-triggered release
48
+ // workflow landed (vault#361). See vault#363.
49
+ const ORIG_HOME = process.env.HOME;
50
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
51
+
52
+ function restoreHomeEnv(): void {
53
+ if (ORIG_HOME === undefined) delete process.env.HOME;
54
+ else process.env.HOME = ORIG_HOME;
55
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
56
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
57
+ }
58
+
40
59
  // ---------------------------------------------------------------------------
41
60
  // Shared test helpers
42
61
  // ---------------------------------------------------------------------------
@@ -439,6 +458,7 @@ describe("export CLI: single-shot", () => {
439
458
  clearVaultStoreCache();
440
459
  fs.rmSync(tmp, { recursive: true, force: true });
441
460
  fs.rmSync(exportDir, { recursive: true, force: true });
461
+ restoreHomeEnv();
442
462
  });
443
463
 
444
464
  test("--git-commit requires an initialized git repo (clear error)", () => {
@@ -702,6 +722,7 @@ describe("export CLI: --watch", () => {
702
722
  clearVaultStoreCache();
703
723
  fs.rmSync(tmp, { recursive: true, force: true });
704
724
  fs.rmSync(exportDir, { recursive: true, force: true });
725
+ restoreHomeEnv();
705
726
  });
706
727
 
707
728
  test(
@@ -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.
@@ -522,6 +522,46 @@ describe("mcp-install flag parsing", () => {
522
522
  expect(res.exitCode).toBe(1);
523
523
  expect(res.stderr).toMatch(/No hub origin configured/);
524
524
  });
525
+
526
+ test("rejects --mint --scope vault:admin pre-flight (hub policy: per-vault admin is non-requestable)", () => {
527
+ // Regression for the symptom Aaron hit on hub 0.5.12-rc.2 / vault
528
+ // 0.4.7-rc.1: `parachute vault mcp-install` with the "admin" mint
529
+ // option sent `vault:default:admin` to `POST /api/auth/mint-token`,
530
+ // and hub responded:
531
+ //
532
+ // Hub mint-token rejected (HTTP 400, invalid_scope):
533
+ // scope vault:default:admin is not requestable via mint-token;
534
+ // use OAuth flow or operator rotation
535
+ //
536
+ // The combination is invalid by hub policy (see
537
+ // `parachute-hub/src/scope-explanations.ts:VAULT_ADMIN_RE` and
538
+ // `api-mint-token.ts`'s non-requestable guard) — per-vault admin
539
+ // is operator-only, mintable only through the session-cookie-gated
540
+ // `/admin/vault-admin-token/:name` SPA path.
541
+ //
542
+ // The fix rejects the combination pre-flight in vault's mcp-install
543
+ // with a clear remediation pointing at `--legacy-pat --scope vault:admin`
544
+ // (which mints a vault-DB pvt_* with admin scope — the right shape
545
+ // for a local MCP entry needing schema management).
546
+ setupBareVault(tmp, "default");
547
+ fs.writeFileSync(path.join(tmp, "operator.token"), "operator-bearer-stub");
548
+ const res = runCli(
549
+ ["mcp-install", "--mint", "--scope", "vault:admin"],
550
+ tmp,
551
+ { PARACHUTE_HUB_ORIGIN: "https://hub.example.org" },
552
+ );
553
+ expect(res.exitCode).toBe(1);
554
+ // Surface the policy reason so the operator knows why this combo is
555
+ // rejected (not a transient bug).
556
+ expect(res.stderr).toMatch(/not requestable via mint-token/);
557
+ // Point at the working remediation.
558
+ expect(res.stderr).toMatch(/--legacy-pat --scope vault:admin/);
559
+ // Pre-flight must fire BEFORE the operator-token / hub-origin checks
560
+ // pass the request to the network — no "Hub unreachable" / "No hub
561
+ // origin configured" leak.
562
+ expect(res.stderr).not.toMatch(/No hub origin configured/);
563
+ expect(res.stderr).not.toMatch(/Hub unreachable/);
564
+ });
525
565
  });
526
566
 
527
567
  // ---------------------------------------------------------------------------
package/src/mcp-tools.ts CHANGED
@@ -178,10 +178,26 @@ function applyTagScopeWrappers(
178
178
  const allowed = await getAllowed();
179
179
  const result = await orig(params);
180
180
  if (!allowed) return result;
181
- // Single-note shape (`{...note}` with `id`) vs list shape (array).
181
+ // Three possible response shapes:
182
+ // - Array (legacy list, no cursor)
183
+ // - `{notes, next_cursor}` (cursor mode, vault#313)
184
+ // - `{...note}` with `id`+`tags` (single-note by id)
182
185
  if (Array.isArray(result)) {
183
186
  return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
184
187
  }
188
+ if (
189
+ result &&
190
+ typeof result === "object" &&
191
+ "notes" in result &&
192
+ Array.isArray((result as any).notes) &&
193
+ "next_cursor" in result
194
+ ) {
195
+ const r = result as { notes: any[]; next_cursor: string | null };
196
+ return {
197
+ notes: r.notes.filter((n: any) => noteWithinTagScope(n, allowed, rawTags)),
198
+ next_cursor: r.next_cursor,
199
+ };
200
+ }
185
201
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
186
202
  return noteWithinTagScope(result as any, allowed, rawTags)
187
203
  ? result