@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.
- package/README.md +44 -10
- 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/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/core/src/schema.ts +98 -2
- package/core/src/store.ts +11 -1
- package/core/src/types.ts +32 -0
- package/package.json +1 -1
- package/src/auth-status.ts +4 -0
- package/src/auto-transcribe.test.ts +116 -0
- package/src/auto-transcribe.ts +48 -0
- package/src/cli.ts +151 -50
- 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 +99 -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 +93 -0
- package/src/module-manifest.ts +94 -0
- package/src/routes.ts +267 -50
- 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 +380 -0
- package/src/self-register.ts +234 -0
- package/src/server.ts +46 -11
- 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.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 {
|
|
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()) {
|
|
@@ -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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
@@ -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("
|
|
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.
|