@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.
- package/.parachute/module.json +1 -1
- package/README.md +78 -41
- package/core/src/connection-pragmas.test.ts +232 -0
- package/core/src/core.test.ts +257 -0
- package/core/src/cursor.test.ts +160 -0
- package/core/src/cursor.ts +272 -0
- package/core/src/mcp.ts +51 -7
- package/core/src/notes.ts +164 -2
- package/core/src/schema.ts +106 -5
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +7 -3
- package/src/auth-status.ts +4 -0
- package/src/auth.test.ts +5 -112
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/backup.ts +17 -3
- package/src/cli.ts +95 -66
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- package/src/export-watch.test.ts +21 -0
- package/src/mcp-install-interactive.test.ts +23 -2
- package/src/mcp-install-interactive.ts +21 -2
- package/src/mcp-install.test.ts +40 -0
- package/src/mcp-tools.ts +17 -1
- package/src/module-config.ts +70 -14
- package/src/module-manifest.test.ts +114 -0
- package/src/module-manifest.ts +104 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routes.ts +268 -51
- package/src/routing.test.ts +102 -99
- package/src/routing.ts +33 -47
- package/src/scribe-discovery.test.ts +77 -0
- package/src/scribe-discovery.ts +91 -0
- package/src/scribe-env.test.ts +66 -1
- package/src/scribe-env.ts +42 -1
- package/src/self-register.test.ts +412 -0
- package/src/self-register.ts +247 -0
- package/src/server.ts +47 -23
- package/src/transcript-note.test.ts +171 -0
- package/src/transcript-note.ts +189 -0
- package/src/transcription-registry.ts +22 -0
- package/src/transcription-worker.test.ts +250 -0
- package/src/transcription-worker.ts +186 -27
- package/src/vault-name.ts +3 -2
- package/src/vault.test.ts +347 -0
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- 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 {
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
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("
|
|
579
|
-
console.log("
|
|
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
|
|
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
|
|
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
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
}
|
|
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
|
-
|
|
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
|
|
3486
|
+
parachute-vault 2fa disable Disable 2FA
|
|
3458
3487
|
parachute-vault 2fa backup-codes Regenerate backup codes
|
|
3459
3488
|
|
|
3460
3489
|
Config:
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
24
|
+
const db = new Database(vaultDbPath(name));
|
|
25
|
+
applyConnectionPragmas(db);
|
|
26
|
+
return db;
|
|
14
27
|
}
|
package/src/export-watch.test.ts
CHANGED
|
@@ -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("
|
|
291
|
-
|
|
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.
|
package/src/mcp-install.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|