@openparachute/vault 0.4.7-rc.2 → 0.4.8-rc.6
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 +0 -1
- 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/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 +57 -48
- package/src/config.test.ts +26 -0
- package/src/config.ts +53 -1
- package/src/db.ts +15 -2
- 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/routes.ts +268 -51
- package/src/routing.test.ts +4 -2
- package/src/routing.ts +4 -4
- 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 +379 -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(
|
|
@@ -3392,7 +3395,13 @@ Vaults:
|
|
|
3392
3395
|
(any shape) instead of minting.
|
|
3393
3396
|
--legacy-pat: mint a vault-DB pvt_*
|
|
3394
3397
|
token (deprecated; for self-hosted-
|
|
3395
|
-
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.
|
|
3396
3405
|
--install-scope local (default) writes
|
|
3397
3406
|
~/.claude.json under
|
|
3398
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
|
}
|
|
@@ -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
|
package/src/module-config.ts
CHANGED
|
@@ -15,15 +15,27 @@
|
|
|
15
15
|
* PUT /.parachute/config is Phase 3 — not implemented here.
|
|
16
16
|
*
|
|
17
17
|
* Fields currently described:
|
|
18
|
-
* - audio_retention:
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
18
|
+
* - audio_retention: per-vault enum, backed by VaultConfig.audio_retention.
|
|
19
|
+
* - port: GlobalConfig.port, exposed read-only.
|
|
20
|
+
* - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
|
|
21
|
+
* Part 2). Three nested fields per design Q4:
|
|
22
|
+
* - enabled: boolean toggle, default false (persisted in
|
|
23
|
+
* GlobalConfig.auto_transcribe.enabled).
|
|
24
|
+
* - scribeUrl: readOnly — resolved per-process from
|
|
25
|
+
* `~/.parachute/services.json` via
|
|
26
|
+
* `scribe-discovery.ts`. Operators can't point at an
|
|
27
|
+
* arbitrary scribe; the discovery layer is the gate.
|
|
28
|
+
* - scribeBearer: writeOnly — sourced from SCRIBE_AUTH_TOKEN env var.
|
|
29
|
+
* Hub install generates one at first boot
|
|
30
|
+
* (see scribe-env.ts:ensureScribeBearer); manual
|
|
31
|
+
* rotation is via `parachute-vault config set`.
|
|
32
|
+
* - scribe_url / scribe_token (deprecated): kept under their legacy names
|
|
33
|
+
* through one release for the hub admin SPA's prior
|
|
34
|
+
* render path; new code should read autoTranscribe.*.
|
|
24
35
|
*/
|
|
25
36
|
|
|
26
37
|
import type { VaultConfig, GlobalConfig } from "./config.ts";
|
|
38
|
+
import { resolveScribeUrl } from "./scribe-discovery.ts";
|
|
27
39
|
|
|
28
40
|
export interface ModuleConfigSchema {
|
|
29
41
|
$schema: string;
|
|
@@ -49,20 +61,54 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
49
61
|
description:
|
|
50
62
|
"What to do with audio attachments after transcription. `keep` leaves the file on disk; `until_transcribed` unlinks on successful transcribe (keeps on failure for retry); `never` unlinks on any terminal state (including failure — no retries).",
|
|
51
63
|
},
|
|
64
|
+
autoTranscribe: {
|
|
65
|
+
type: "object",
|
|
66
|
+
title: "Auto-transcribe voice uploads",
|
|
67
|
+
description:
|
|
68
|
+
"When enabled, audio attachments (mime-type prefix `audio/`) are automatically sent to scribe and the resulting transcript lands as a sibling `<attachment-path>.transcript.md` note. Scribe must be reachable for transcription to succeed; failures are recorded as a transcript note with `transcript_status: failed`.",
|
|
69
|
+
properties: {
|
|
70
|
+
enabled: {
|
|
71
|
+
type: "boolean",
|
|
72
|
+
default: false,
|
|
73
|
+
title: "Enable auto-transcription",
|
|
74
|
+
description:
|
|
75
|
+
"Master toggle. When false, audio uploads land normally without any scribe interaction. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
|
|
76
|
+
},
|
|
77
|
+
scribeUrl: {
|
|
78
|
+
type: "string",
|
|
79
|
+
format: "uri",
|
|
80
|
+
readOnly: true,
|
|
81
|
+
title: "Scribe URL",
|
|
82
|
+
description:
|
|
83
|
+
"URL of the scribe service. Auto-populated from `~/.parachute/services.json` at vault startup (or from the SCRIBE_URL env var when set). Read-only — operators can't point at an arbitrary scribe.",
|
|
84
|
+
},
|
|
85
|
+
scribeBearer: {
|
|
86
|
+
type: "string",
|
|
87
|
+
writeOnly: true,
|
|
88
|
+
title: "Scribe auth bearer",
|
|
89
|
+
description:
|
|
90
|
+
"Shared bearer for the vault→scribe loopback contract. Hub install generates one at first boot. Write-only — never returned by GET.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
// Legacy aliases kept for back-compat with callers that read the
|
|
95
|
+
// pre-vault#353 shape. New consumers should read `autoTranscribe.*`.
|
|
52
96
|
scribe_url: {
|
|
53
97
|
type: "string",
|
|
54
98
|
format: "uri",
|
|
55
|
-
title: "Scribe URL",
|
|
99
|
+
title: "Scribe URL (deprecated alias)",
|
|
56
100
|
description:
|
|
57
|
-
"
|
|
101
|
+
"Legacy alias for `autoTranscribe.scribeUrl`. Will be removed in a future release.",
|
|
58
102
|
readOnly: true,
|
|
103
|
+
deprecated: true,
|
|
59
104
|
},
|
|
60
105
|
scribe_token: {
|
|
61
106
|
type: "string",
|
|
62
|
-
title: "Scribe auth token",
|
|
107
|
+
title: "Scribe auth token (deprecated alias)",
|
|
63
108
|
description:
|
|
64
|
-
"
|
|
109
|
+
"Legacy alias for `autoTranscribe.scribeBearer`. Will be removed in a future release.",
|
|
65
110
|
writeOnly: true,
|
|
111
|
+
deprecated: true,
|
|
66
112
|
},
|
|
67
113
|
port: {
|
|
68
114
|
type: "integer",
|
|
@@ -77,18 +123,28 @@ export function buildConfigSchema(): ModuleConfigSchema {
|
|
|
77
123
|
}
|
|
78
124
|
|
|
79
125
|
/**
|
|
80
|
-
* Effective config values, with `writeOnly` fields stripped. `
|
|
81
|
-
* declared `writeOnly` and
|
|
82
|
-
* set in the environment.
|
|
126
|
+
* Effective config values, with `writeOnly` fields stripped. `scribeBearer`
|
|
127
|
+
* (and its legacy alias `scribe_token`) are declared `writeOnly` and never
|
|
128
|
+
* returned, even when set in the environment.
|
|
83
129
|
*/
|
|
84
130
|
export function buildConfigValues(
|
|
85
131
|
vaultConfig: VaultConfig,
|
|
86
132
|
globalConfig: GlobalConfig,
|
|
87
133
|
env: { SCRIBE_URL?: string | undefined } = process.env as { SCRIBE_URL?: string },
|
|
88
134
|
): Record<string, unknown> {
|
|
135
|
+
// Resolve scribe URL through the discovery layer so the GET shape reflects
|
|
136
|
+
// what the worker will actually use (services.json > SCRIBE_URL > unset).
|
|
137
|
+
// Pass env through so the test harness's override is honored.
|
|
138
|
+
const scribeUrl = resolveScribeUrl(env as NodeJS.ProcessEnv) ?? "";
|
|
89
139
|
return {
|
|
90
140
|
audio_retention: vaultConfig.audio_retention ?? "keep",
|
|
91
|
-
|
|
141
|
+
autoTranscribe: {
|
|
142
|
+
enabled: globalConfig.auto_transcribe?.enabled ?? false,
|
|
143
|
+
scribeUrl,
|
|
144
|
+
},
|
|
145
|
+
// Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
|
|
146
|
+
// pre-vault#353 shape don't regress.
|
|
147
|
+
scribe_url: scribeUrl,
|
|
92
148
|
port: globalConfig.port,
|
|
93
149
|
};
|
|
94
150
|
}
|